master
ブランチ master
LittleBeast / src / Std / Lib / Db / Mysql.php
63.5 KB、1945行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Db;
39
40use Std\Lib\Crypto\Rsa;
41use Std\Lib\Tls;
42use Std\Lib\Tls\TlsException;
43
44/**
45 * PHP純正のMySQL(MariaDBも)ドライバ。mysqli・pdo_mysql・socketsのモジュールが不要。
46 *
47 * 対応:TCP・UNIXソケット、TLS、認証プラグイン(mysql_native_password、caching_sha2_password、
48 * sha256_password、mysql_clear_password)、テキスト・バイナリ(プリペアドステートメント)プロトコル、
49 * 16MiB以上のパケット、複数の結果(CALL等)、トランザクション
50 * 未対応:LOAD DATA LOCAL INFILE、MariaDBのclient_ed25519、圧縮
51 *
52 * 普通はStd\Lib\Db経由で使う。直接使う場合:
53 * $my = new Mysql(['host' => '127.0.0.1', 'username' => 'lb', 'password' => 'ひみつ', 'dbname' => 'lb']);
54 * $my->connect();
55 * $id = $my->prepare('SELECT * FROM users WHERE id = ?');
56 * $res = $my->execute($id, [1]); // $res['rows']
57 * $my->demolish($id);
58 */
59class Mysql implements DbInterface {
60 // コマンド
61 private const COM_QUIT = 0x01;
62 private const COM_INIT_DB = 0x02;
63 private const COM_QUERY = 0x03;
64 private const COM_PING = 0x0E;
65 private const COM_STMT_PREPARE = 0x16;
66 private const COM_STMT_EXECUTE = 0x17;
67 private const COM_STMT_CLOSE = 0x19;
68
69 // クライアント機能フラグ
70 private const CLIENT_LONG_PASSWORD = 0x00000001;
71 private const CLIENT_LONG_FLAG = 0x00000004;
72 private const CLIENT_CONNECT_WITH_DB = 0x00000008;
73 private const CLIENT_PROTOCOL_41 = 0x00000200;
74 private const CLIENT_SSL = 0x00000800;
75 private const CLIENT_TRANSACTIONS = 0x00002000;
76 private const CLIENT_SECURE_CONNECTION = 0x00008000;
77 private const CLIENT_MULTI_RESULTS = 0x00020000;
78 private const CLIENT_PS_MULTI_RESULTS = 0x00040000;
79 private const CLIENT_PLUGIN_AUTH = 0x00080000;
80 private const CLIENT_PLUGIN_AUTH_LENENC = 0x00200000;
81
82 // サーバーの状況
83 private const SERVER_STATUS_IN_TRANS = 0x0001;
84 private const SERVER_MORE_RESULTS_EXISTS = 0x0008;
85
86 // 列のフラグ
87 private const UNSIGNED_FLAG = 0x0020;
88
89 // 列の種類
90 public const TYPE_DECIMAL = 0x00;
91 public const TYPE_TINY = 0x01;
92 public const TYPE_SHORT = 0x02;
93 public const TYPE_LONG = 0x03;
94 public const TYPE_FLOAT = 0x04;
95 public const TYPE_DOUBLE = 0x05;
96 public const TYPE_NULL = 0x06;
97 public const TYPE_TIMESTAMP = 0x07;
98 public const TYPE_LONGLONG = 0x08;
99 public const TYPE_INT24 = 0x09;
100 public const TYPE_DATE = 0x0A;
101 public const TYPE_TIME = 0x0B;
102 public const TYPE_DATETIME = 0x0C;
103 public const TYPE_YEAR = 0x0D;
104 public const TYPE_NEWDATE = 0x0E;
105 public const TYPE_VARCHAR = 0x0F;
106 public const TYPE_BIT = 0x10;
107 public const TYPE_JSON = 0xF5;
108 public const TYPE_NEWDECIMAL = 0xF6;
109 public const TYPE_ENUM = 0xF7;
110 public const TYPE_SET = 0xF8;
111 public const TYPE_TINY_BLOB = 0xF9;
112 public const TYPE_MEDIUM_BLOB = 0xFA;
113 public const TYPE_LONG_BLOB = 0xFB;
114 public const TYPE_BLOB = 0xFC;
115 public const TYPE_VAR_STRING = 0xFD;
116 public const TYPE_STRING = 0xFE;
117 public const TYPE_GEOMETRY = 0xFF;
118
119 // 文字セット => 照合順序のID(ハンドシェイクで送る)
120 private const CHARSETS = [
121 'utf8mb4' => 45,
122 'utf8mb3' => 33,
123 'utf8' => 33,
124 'latin1' => 8,
125 'ascii' => 11,
126 'binary' => 63,
127 'ujis' => 12,
128 'sjis' => 13,
129 'cp932' => 95,
130 'eucjpms' => 97,
131 ];
132
133 private const PLUGINS = ['mysql_native_password', 'caching_sha2_password', 'sha256_password', 'mysql_clear_password'];
134 private const MAX_PACKET = 0x01000000;
135
136 /** @var resource|null */
137 private $socket = null;
138 private bool $connected = false;
139 private bool $secure = false;
140 private bool $debug = false;
141 private array $packetLog = [];
142 private array $serverInfo = [];
143 public string $username;
144 private string $password;
145 public string $dbname;
146 public string $host;
147 private int $port;
148 private string $socketPath;
149 private string $charset;
150 private bool|array $tls;
151 private int $timeout;
152 private string $pubkey;
153 private int $seq = 0;
154 private int $status = 0;
155 private string $plugin = '';
156
157 private array $prepared = [];
158
159 /**
160 * コンストラクタ
161 *
162 * 接続に必要な設定を初期化します。$infoを省略するとDBINFOを使います。
163 *
164 * @param array|null $info host、port(3306)、socket(UNIXソケットのパス、指定するとhost・portの代わりに使う)、
165 * username、password、dbname、charset(utf8mb4)、
166 * tls(true=証明書を確認するTLS、又はStd\Lib\Tlsのオプションの配列:cafile、verify_peer等)、
167 * timeout(秒、10)、pubkey(caching_sha2_password・sha256_password用のサーバーの公開鍵のPEMファイル、任意)、debug
168 * @throws \Exception $infoを省略し、DB_ENABLEDが無効な場合
169 */
170 public function __construct(?array $info = null) {
171 if ($info === null && !(defined('DB_ENABLED') && DB_ENABLED)) {
172 throw new \Exception('データベースは無効です。');
173 }
174
175 $info ??= DBINFO;
176 $this->host = (string)($info['host'] ?? '127.0.0.1');
177 $this->port = (int)($info['port'] ?? 3306);
178 $this->socketPath = (string)($info['socket'] ?? '');
179 $this->username = (string)($info['username'] ?? '');
180 $this->password = (string)($info['password'] ?? '');
181 $this->dbname = (string)($info['dbname'] ?? '');
182 $this->charset = strtolower((string)($info['charset'] ?? 'utf8mb4'));
183 $this->tls = $info['tls'] ?? false;
184 $this->timeout = max(1, (int)($info['timeout'] ?? 10));
185 $this->pubkey = (string)($info['pubkey'] ?? '');
186 $this->debug = (bool)($info['debug'] ?? false);
187
188 if (!isset(self::CHARSETS[$this->charset])) {
189 throw new \InvalidArgumentException("未対応の文字セット: {$this->charset}");
190 }
191 }
192
193 /**
194 * デストラクタ
195 *
196 * オブジェクト破棄時に接続を閉じます。
197 */
198 public function __destruct() {
199 $this->close();
200 }
201
202 /**
203 * デバッギングの有無
204 *
205 * 有効にすると、送受信したパケットを出力し、ログに残します。
206 *
207 * @param bool $debug デバッグモードを有効にするかどうか
208 * @return Mysql 自身を返す(メソッドチェーン用)
209 */
210 public function setDebug(bool $debug): Mysql {
211 $this->debug = $debug;
212 return $this;
213 }
214
215 /**
216 * パケットログの取得
217 *
218 * @return array 送信および受信したパケットのログ
219 */
220 public function getPacketLog(): array {
221 return $this->packetLog;
222 }
223
224 /**
225 * サーバーの情報(挨拶パケットから)
226 *
227 * @return array protocol、version、threadId、capabilities、charset、status、plugin(サーバーの既定)、authPlugin(実際に使った)
228 */
229 public function getServerInfo(): array {
230 $info = $this->serverInfo;
231 unset($info['scramble']);
232 $info['authPlugin'] = $this->plugin;
233 return $info;
234 }
235
236 /**
237 * MySQLサーバーに接続
238 *
239 * サーバーに接続後、(設定された場合)TLSを開始し、認証とデータベース選択を行います。
240 *
241 * @return bool 接続成功時はtrue
242 * @throws DbException 接続または認証に失敗した場合
243 */
244 public function connect(): bool {
245 if ($this->connected) return true;
246
247 $target = $this->socketPath !== '' ? "unix://{$this->socketPath}" : "tcp://{$this->host}:{$this->port}";
248 $errno = 0;
249 $errstr = '';
250 // 自分のコンテキスト(既定のコンテキストだと、TLSの設定が他の接続に残る)
251 $socket = @stream_socket_client($target, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, stream_context_create());
252 if ($socket === false) {
253 $this->fail("ソケットに接続に失敗: {$errstr} ({$errno})");
254 }
255 stream_set_timeout($socket, $this->timeout);
256 $this->socket = $socket;
257 $this->secure = $this->socketPath !== '';
258 $this->seq = 0;
259
260 try {
261 $this->parseServerGreeting($this->readPacket());
262 $this->authenticate();
263 $this->connected = true;
264
265 // CONNECT_WITH_DBに対応していないサーバー
266 if ($this->dbname !== '' && !($this->serverInfo['capabilities'] & self::CLIENT_CONNECT_WITH_DB)) {
267 $this->selectDatabase($this->dbname);
268 }
269 } catch (\Throwable $e) {
270 $this->drop();
271 throw $e;
272 }
273
274 return true;
275 }
276
277 /**
278 * 接続を閉じる
279 *
280 * COM_QUITコマンドを送信し、ソケットを閉じます。
281 *
282 * @return void
283 */
284 public function close(): void {
285 if ($this->socket && $this->connected) {
286 try {
287 $this->sendCommand(self::COM_QUIT);
288 } catch (\Throwable $e) {
289 // 既に切れている
290 }
291 }
292 $this->drop();
293 }
294
295 /**
296 * 接続しているかどうか
297 *
298 * @return bool
299 */
300 public function isConnected(): bool {
301 return $this->connected;
302 }
303
304 /**
305 * 接続がまだ生きているか確認する(COM_PING)
306 *
307 * @return bool サーバーが応答したらtrue
308 */
309 public function ping(): bool {
310 if (!$this->connected) return false;
311
312 try {
313 $this->sendCommand(self::COM_PING);
314 $res = $this->readPacket();
315 } catch (\Throwable $e) {
316 return false;
317 }
318
319 if (ord($res[0] ?? "\xFF") !== 0x00) return false;
320 $this->parseOkPacket($res);
321 return true;
322 }
323
324 /**
325 * 利用するデータベースを選択する
326 *
327 * COM_INIT_DBコマンドを使用してデータベースを選択します。
328 *
329 * @param string $database データベース名
330 * @return bool 成功時はtrue
331 * @throws DbException データベース選択に失敗した場合
332 */
333 public function selectDatabase(string $database): bool {
334 $this->requireConnection();
335
336 $this->sendCommand(self::COM_INIT_DB, $database);
337 $res = $this->readPacket();
338
339 if (ord($res[0]) === 0xFF) {
340 $this->failPacket('データベースの選択に失敗', $res);
341 }
342
343 $this->parseOkPacket($res);
344 $this->dbname = $database;
345 return true;
346 }
347
348 /**
349 * プリペアドステートメントの準備
350 *
351 * SQLクエリをプリペアドステートメントとして準備し、ステートメントIDを返します。
352 *
353 * @param string $query プレースホルダ付きSQLクエリ(例: "SELECT * FROM users WHERE id = ?")
354 * @return int 成功時はステートメントID
355 * @throws DbException 準備に失敗した場合
356 */
357 public function prepare(string $query): int {
358 $this->requireConnection();
359
360 $this->sendCommand(self::COM_STMT_PREPARE, $query);
361 $res = $this->readPacket();
362
363 if (ord($res[0]) === 0xFF) {
364 $this->failPacket('準備に失敗', $res);
365 }
366
367 // 0x00、ステートメントID(4)、列数(2)、パラメータ数(2)、予約(1)、警告数(2)
368 $head = unpack('Vid/vcols/vparams', substr($res, 1, 8));
369 $statementId = $head['id'];
370
371 $this->prepared[$statementId] = [
372 'num_params' => $head['params'],
373 'num_columns' => $head['cols'],
374 'params' => [],
375 'columns' => [],
376 ];
377
378 if ($head['params'] > 0) {
379 for ($i = 0; $i < $head['params']; $i++) {
380 $this->prepared[$statementId]['params'][] = $this->parseFieldPacket($this->readPacket());
381 }
382 $this->readEof();
383 }
384
385 if ($head['cols'] > 0) {
386 for ($i = 0; $i < $head['cols']; $i++) {
387 $this->prepared[$statementId]['columns'][] = $this->parseFieldPacket($this->readPacket());
388 }
389 $this->readEof();
390 }
391
392 return $statementId;
393 }
394
395 /**
396 * プリペアドステートメントの実行
397 *
398 * 指定されたステートメントIDとパラメータを使用してクエリを実行します。
399 * int=BIGINT、float=DOUBLE、bool=TINYINT、null=NULL、DateTimeInterface=「Y-m-d H:i:s.u」、
400 * BackedEnum=その値、その他=文字列として送ります。
401 *
402 * @param int $statementId プリペアドステートメントID
403 * @param array $params パラメータ値の配列(順番通り)
404 * @return array 結果(DbInterface::query()と同じ形)
405 * @throws DbException 実行に失敗した場合
406 */
407 public function execute(int $statementId, array $params = []): array {
408 $this->requireConnection();
409
410 if (!isset($this->prepared[$statementId])) {
411 $this->fail("不正なステートメントID: {$statementId}");
412 }
413
414 $stmtInfo = $this->prepared[$statementId];
415 $params = array_values($params);
416 if (count($params) !== $stmtInfo['num_params']) {
417 $this->fail("パラメータ数が一致しません: 期待 {$stmtInfo['num_params']}, 取得 ".count($params));
418 }
419
420 $data = chr(self::COM_STMT_EXECUTE);
421 $data .= pack('V', $statementId);
422 $data .= chr(0); // 0 = カーソルなし
423 $data .= pack('V', 1); // 繰り返し数(常に1)
424
425 if ($stmtInfo['num_params'] > 0) {
426 $nullBitmap = str_repeat("\0", intdiv($stmtInfo['num_params'] + 7, 8));
427 $paramTypes = '';
428 $paramValues = '';
429
430 foreach ($params as $k => $param) {
431 if ($param instanceof \BackedEnum) $param = $param->value;
432 if ($param instanceof \DateTimeInterface) $param = $param->format('Y-m-d H:i:s.u');
433
434 if ($param === null) {
435 $nullBitmap[$k >> 3] = chr(ord($nullBitmap[$k >> 3]) | (1 << ($k & 7)));
436 $paramTypes .= pack('v', self::TYPE_NULL);
437 } else if (is_bool($param)) {
438 $paramTypes .= pack('v', self::TYPE_TINY);
439 $paramValues .= chr((int)$param);
440 } else if (is_int($param)) {
441 $paramTypes .= pack('v', self::TYPE_LONGLONG);
442 $paramValues .= pack('P', $param);
443 } else if (is_float($param)) {
444 $paramTypes .= pack('v', self::TYPE_DOUBLE);
445 $paramValues .= pack('e', $param);
446 } else if (is_scalar($param) || $param instanceof \Stringable) {
447 $param = (string)$param;
448 $paramTypes .= pack('v', self::TYPE_VAR_STRING);
449 $paramValues .= $this->encodeLengthEncodedInteger(strlen($param)).$param;
450 } else {
451 $this->fail("パラメータ{$k}の型は未対応です: ".get_debug_type($param));
452 }
453 }
454
455 $data .= $nullBitmap.chr(1).$paramTypes.$paramValues; // 1 = 新しいパラメータの種類
456 }
457
458 $this->seq = 0;
459 $this->sendPacket($data);
460 return $this->readResult($this->readPacket(), true, '実行に失敗');
461 }
462
463 /**
464 * プリペアドステートメントの解放
465 *
466 * 指定されたステートメントIDを解放し、リソースをクリーンアップします。
467 *
468 * @param int $statementId プリペアドステートメントID
469 * @return bool 成功時はtrue
470 */
471 public function demolish(int $statementId): bool {
472 if (!isset($this->prepared[$statementId])) return false;
473 unset($this->prepared[$statementId]);
474 if (!$this->connected) return false;
475
476 $this->sendCommand(self::COM_STMT_CLOSE, pack('V', $statementId)); // 返事はない
477 return true;
478 }
479
480 /**
481 * SQLクエリの実行
482 *
483 * COM_QUERYを使用してSQLクエリを実行し、結果を返します。
484 *
485 * @param string $query 実行するSQLクエリ
486 * @return array 結果(DbInterface::query()を参照)
487 * @throws DbException クエリ実行に失敗した場合
488 */
489 public function query(string $query): array {
490 $this->requireConnection();
491
492 $this->sendCommand(self::COM_QUERY, $query);
493 return $this->readResult($this->readPacket(), false, 'クエリに失敗');
494 }
495
496 /**
497 * トランザクションを開始する
498 *
499 * @return bool
500 * @throws DbException
501 */
502 public function beginTransaction(): bool {
503 $this->query('START TRANSACTION');
504 return true;
505 }
506
507 /**
508 * トランザクションをコミットする
509 *
510 * @return bool
511 * @throws DbException
512 */
513 public function commit(): bool {
514 $this->query('COMMIT');
515 return true;
516 }
517
518 /**
519 * トランザクションをロールバックする
520 *
521 * @return bool
522 * @throws DbException
523 */
524 public function rollback(): bool {
525 $this->query('ROLLBACK');
526 return true;
527 }
528
529 /**
530 * トランザクション中かどうか(サーバーの状況フラグから)
531 *
532 * @return bool
533 */
534 public function inTransaction(): bool {
535 return $this->connected && ($this->status & self::SERVER_STATUS_IN_TRANS) !== 0;
536 }
537
538 /**
539 * 識別子(テーブル名・列名)を囲む
540 *
541 * @param string $name 識別子
542 * @return string `name`
543 */
544 public function quoteIdentifier(string $name): string {
545 return '`'.str_replace('`', '``', $name).'`';
546 }
547
548 /**
549 * パケットログをファイルに保存する
550 *
551 * デバッグ用に収集したパケットログを指定されたファイルに保存します。
552 *
553 * @param string $filename 保存先ファイル名(ROOT/log/の中)
554 * @return bool|int 成功時は書き込んだバイト数、失敗時はfalse
555 */
556 public function savePacketLogToFile(string $filename): bool|int {
557 $output = '';
558
559 foreach ($this->packetLog as $index => $packetInfo) {
560 $direction = $packetInfo['direction'];
561 $length = $packetInfo['length'];
562 $seqNum = $packetInfo['seqNum'];
563 $data = $packetInfo['data'];
564 $timestamp = date('Y-m-d H:i:s', (int)$packetInfo['timestamp']);
565
566 $output .= "=== パケット #{$index} ({$timestamp}) {$direction} ";
567 $output .= "(長さ: {$length}, シーケンス: {$seqNum}) ===\n";
568 $output .= "16進数: ".$this->hexDump($data)."\n";
569 $output .= "ASCII: ".$this->asciiDump($data)."\n";
570 $output .= "==========================================\n\n";
571 }
572
573 return file_put_contents(ROOT.'/log/'.basename($filename), $output);
574 }
575
576 // 機能性メソッド
577
578 /**
579 * MySQLサーバーで認証する
580 *
581 * 必要ならTLSを開始し、クライアント機能フラグと認証情報を送信してサーバー認証を行います。
582 * サーバーが別の認証プラグインを求めたら(Auth Switch)、それに従います。
583 *
584 * @return bool 認証成功時はtrue
585 * @throws DbException 認証に失敗した場合
586 */
587 private function authenticate(): bool {
588 $server = $this->serverInfo['capabilities'];
589 if (!($server & self::CLIENT_PROTOCOL_41)) {
590 $this->fail('サーバーが古すぎます(MySQL 4.1以上が必要)');
591 }
592
593 $caps = self::CLIENT_LONG_PASSWORD | self::CLIENT_LONG_FLAG | self::CLIENT_PROTOCOL_41
594 | self::CLIENT_TRANSACTIONS | self::CLIENT_SECURE_CONNECTION | self::CLIENT_MULTI_RESULTS
595 | self::CLIENT_PS_MULTI_RESULTS | self::CLIENT_PLUGIN_AUTH | self::CLIENT_PLUGIN_AUTH_LENENC;
596 if ($this->dbname !== '') $caps |= self::CLIENT_CONNECT_WITH_DB;
597 $caps &= $server;
598
599 $charset = chr(self::CHARSETS[$this->charset]);
600 $head = pack('VV', 0, self::MAX_PACKET).$charset.str_repeat("\0", 23);
601
602 // TLS:SSLリクエストを送ってから暗号化する
603 if ($this->tls !== false) {
604 if (!($server & self::CLIENT_SSL)) {
605 $this->fail('サーバーがTLSに対応していません');
606 }
607 $caps |= self::CLIENT_SSL;
608 $this->sendPacket(substr_replace($head, pack('V', $caps), 0, 4));
609 $this->startTls();
610 }
611
612 $plugin = in_array($this->serverInfo['plugin'], self::PLUGINS, true) ? $this->serverInfo['plugin'] : 'mysql_native_password';
613 $scramble = $this->serverInfo['scramble'];
614 $auth = $this->authResponse($plugin, $scramble);
615
616 $data = substr_replace($head, pack('V', $caps), 0, 4);
617 $data .= $this->username."\0";
618 if ($caps & self::CLIENT_PLUGIN_AUTH_LENENC) {
619 $data .= $this->encodeLengthEncodedInteger(strlen($auth)).$auth;
620 } else {
621 $data .= chr(strlen($auth)).$auth;
622 }
623 if ($caps & self::CLIENT_CONNECT_WITH_DB) $data .= $this->dbname."\0";
624 if ($caps & self::CLIENT_PLUGIN_AUTH) $data .= $plugin."\0";
625
626 $this->sendPacket($data);
627
628 while (true) {
629 $res = $this->readPacket();
630 $first = ord($res[0] ?? "\xFF");
631
632 if ($first === 0x00) {
633 $this->parseOkPacket($res);
634 $this->plugin = $plugin;
635 return true;
636 }
637
638 if ($first === 0xFF) {
639 $this->failPacket('認証に失敗', $res);
640 }
641
642 if ($first === 0xFE) {
643 // Auth Switch:プラグイン名\0、新しいスクランブル
644 if (strlen($res) === 1) {
645 $this->fail('古い認証方法(4.1以前)は未対応です');
646 }
647 $end = strpos($res, "\0", 1);
648 $plugin = substr($res, 1, ($end === false ? strlen($res) : $end) - 1);
649 $scramble = $end === false ? '' : substr($res, $end + 1);
650 if (str_ends_with($scramble, "\0")) $scramble = substr($scramble, 0, -1);
651 if (!in_array($plugin, self::PLUGINS, true)) {
652 $this->fail("未対応の認証プラグイン: {$plugin}");
653 }
654 $this->sendPacket($this->authResponse($plugin, $scramble));
655 continue;
656 }
657
658 if ($first === 0x01) {
659 // Auth More Data(caching_sha2_password・sha256_password)
660 $more = substr($res, 1);
661 if (str_starts_with($more, '-----BEGIN')) {
662 $this->sendPacket($this->rsaEncrypt($more, $scramble));
663 } else if ($plugin === 'caching_sha2_password' && $more === "\x03") {
664 continue; // 高速認証に成功、OKが続く
665 } else if ($plugin === 'caching_sha2_password' && $more === "\x04") {
666 // 完全な認証:TLSならそのまま、それ以外は公開鍵で暗号化
667 if ($this->secure) {
668 $this->sendPacket($this->password."\0");
669 } else if ($this->pubkey !== '') {
670 $this->sendPacket($this->rsaEncrypt($this->readPubkey(), $scramble));
671 } else {
672 $this->sendPacket("\x02"); // 公開鍵を求める
673 }
674 } else {
675 $this->fail('想定外の認証データ');
676 }
677 continue;
678 }
679
680 $this->fail(sprintf('想定外の認証応答: 0x%02X', $first));
681 }
682 }
683
684 /**
685 * 認証プラグイン毎の認証データ
686 *
687 * @param string $plugin プラグイン名
688 * @param string $scramble サーバーから提供されたスクランブル文字列
689 * @return string
690 * @throws DbException
691 */
692 private function authResponse(string $plugin, string $scramble): string {
693 switch ($plugin) {
694 case 'mysql_native_password':
695 return $this->password === '' ? '' : $this->scramblePassword($this->password, $scramble);
696 case 'caching_sha2_password':
697 return $this->password === '' ? '' : $this->scrambleSha256($this->password, $scramble);
698 case 'sha256_password':
699 if ($this->password === '') return "\0";
700 if ($this->secure) return $this->password."\0";
701 if ($this->pubkey !== '') return $this->rsaEncrypt($this->readPubkey(), $scramble);
702 return "\x01"; // 公開鍵を求める
703 case 'mysql_clear_password':
704 if (!$this->secure) {
705 $this->fail('mysql_clear_passwordはTLS又はUNIXソケットでしか使えません');
706 }
707 return $this->password."\0";
708 }
709
710 $this->fail("未対応の認証プラグイン: {$plugin}");
711 }
712
713 /**
714 * TLSを開始する
715 *
716 * @return void
717 * @throws DbException
718 */
719 private function startTls(): void {
720 $opts = is_array($this->tls) ? $this->tls : [];
721 $opts += ['peer_name' => $this->host];
722
723 try {
724 $this->socket = Tls::enable($this->socket, $opts);
725 } catch (TlsException $e) {
726 $this->fail('TLSの開始に失敗: '.$e->getMessage());
727 }
728 $this->secure = true;
729 }
730
731 /**
732 * パスワードをサーバーの公開鍵で暗号化する(caching_sha2_password・sha256_password)
733 *
734 * @param string $pem 公開鍵(PEM)
735 * @param string $scramble スクランブル文字列
736 * @return string
737 * @throws DbException
738 */
739 private function rsaEncrypt(string $pem, string $scramble): string {
740 $pw = $this->password."\0";
741 $xor = '';
742 $len = strlen($scramble);
743 for ($i = 0; $i < strlen($pw); $i++) {
744 $xor .= $pw[$i] ^ $scramble[$i % $len];
745 }
746
747 // OAEP(SHA-1、MySQLのRSA_PKCS1_OAEP_PADDINGと同じ)
748 try {
749 return Rsa::fromPem($pem)->encrypt($xor, 'sha1');
750 } catch (\InvalidArgumentException $e) {
751 $this->fail('パスワードの暗号化に失敗: '.$e->getMessage());
752 }
753 }
754
755 /**
756 * 設定された公開鍵のファイルを読む
757 *
758 * @return string PEM
759 * @throws DbException
760 */
761 private function readPubkey(): string {
762 $pem = @file_get_contents($this->pubkey);
763 if ($pem === false) {
764 $this->fail("公開鍵を読めません: {$this->pubkey}");
765 }
766 return $pem;
767 }
768
769 /**
770 * サーバーからの結果を読む
771 *
772 * OKパケット、又は結果セット(列の説明、行)を解析します。
773 * 結果が複数ある場合(CALL等)、2つ目からは「more」に入ります。
774 *
775 * @param string $firstPacket 最初のパケット
776 * @param bool $binary バイナリプロトコル(プリペアドステートメント)の行かどうか
777 * @param string $what 失敗した時のメッセージ
778 * @return array fields、rows、affectedRows、insertId、serverStatus、warningCount、info(、more)
779 * @throws DbException
780 */
781 private function readResult(string $firstPacket, bool $binary, string $what): array {
782 $first = ord($firstPacket[0] ?? "\xFF");
783
784 if ($first === 0xFF) {
785 $this->failPacket($what, $firstPacket);
786 }
787
788 if ($first === 0xFB) {
789 // LOCAL INFILEリクエスト(CLIENT_LOCAL_FILESを送っていないので、普通は来ない):断る
790 $this->sendPacket('');
791 $this->readPacket();
792 $this->fail('LOCAL INFILEリクエストは未対応です');
793 }
794
795 if ($first === 0x00) {
796 $res = ['fields' => [], 'rows' => []] + $this->parseOkPacket($firstPacket);
797 } else {
798 $res = $this->parseResultSet($firstPacket, $binary);
799 }
800
801 if ($this->status & self::SERVER_MORE_RESULTS_EXISTS) {
802 $next = $this->readResult($this->readPacket(), $binary, $what);
803 $more = $next['more'] ?? [];
804 unset($next['more']);
805 $res['more'] = array_merge([$next], $more);
806 }
807
808 return $res;
809 }
810
811 /**
812 * サーバーから結果セットを解析する
813 *
814 * クエリ結果のフィールドと行データを解析して返します。
815 *
816 * @param string $firstPacket 最初の結果セットパケット(列数)
817 * @param bool $binary バイナリプロトコルの行かどうか
818 * @return array フィールドと行データの配列
819 * @throws DbException EOFパケットが期待通りに受信できない場合
820 */
821 private function parseResultSet(string $firstPacket, bool $binary): array {
822 $pos = 0;
823 $fieldCnt = (int)$this->readLengthEncodedInteger($firstPacket, $pos);
824
825 $fields = [];
826 for ($i = 0; $i < $fieldCnt; $i++) {
827 $fields[] = $this->parseFieldPacket($this->readPacket());
828 }
829 $this->readEof();
830
831 $rows = [];
832 $warnings = 0;
833 while (true) {
834 $rowPacket = $this->readPacket();
835 $first = ord($rowPacket[0] ?? "\xFF");
836
837 if ($first === 0xFF) {
838 $this->failPacket('行の読み込みに失敗', $rowPacket);
839 }
840
841 // 行データの終了を示すEOFパケットを確認
842 if ($first === 0xFE && strlen($rowPacket) < 9) {
843 $eof = $this->parseEofPacket($rowPacket);
844 $warnings = $eof['warningCount'];
845 break;
846 }
847
848 $rows[] = $binary ? $this->parseBinaryRowPacket($rowPacket, $fields) : $this->parseRowPacket($rowPacket, $fields);
849 }
850
851 return [
852 'fields' => $fields,
853 'rows' => $rows,
854 'affectedRows' => 0,
855 'insertId' => 0,
856 'serverStatus' => $this->status,
857 'warningCount' => $warnings,
858 'info' => '',
859 ];
860 }
861
862 /**
863 * EOFパケットを読む(列の説明の後)
864 *
865 * @return void
866 * @throws DbException
867 */
868 private function readEof(): void {
869 $eofPacket = $this->readPacket();
870 if (ord($eofPacket[0] ?? "\0") === 0xFF) {
871 $this->failPacket('結果の読み込みに失敗', $eofPacket);
872 }
873 if (ord($eofPacket[0] ?? "\0") !== 0xFE) {
874 $this->fail('フィールド説明の後にEOFパケットが期待されます');
875 }
876 $this->parseEofPacket($eofPacket);
877 }
878
879 /**
880 * フィールドパケットを解析する
881 *
882 * フィールドのメタデータ(Protocol::ColumnDefinition41)を解析して返します。
883 *
884 * @param string $packet フィールドパケット
885 * @return array catalog、db、table、org_table、name、org_name、charset、length、type、flags、decimals
886 */
887 private function parseFieldPacket(string $packet): array {
888 $pos = 0;
889 $field = [];
890
891 $field['catalog'] = (string)$this->readLengthEncodedString($packet, $pos); // 常に「def」
892 $field['db'] = (string)$this->readLengthEncodedString($packet, $pos);
893 $field['table'] = (string)$this->readLengthEncodedString($packet, $pos);
894 $field['org_table'] = (string)$this->readLengthEncodedString($packet, $pos);
895 $field['name'] = (string)$this->readLengthEncodedString($packet, $pos);
896 $field['org_name'] = (string)$this->readLengthEncodedString($packet, $pos);
897
898 // 固定長の部分の長さ(常に0x0C)
899 $this->readLengthEncodedInteger($packet, $pos);
900
901 $fixed = unpack('vcharset/Vlength/Ctype/vflags/Cdecimals', str_pad(substr($packet, $pos, 10), 10, "\0"));
902 $field['charset'] = $fixed['charset'];
903 $field['length'] = $fixed['length'];
904 $field['type'] = $fixed['type'];
905 $field['flags'] = $fixed['flags'];
906 $field['decimals'] = $fixed['decimals'];
907
908 return $field;
909 }
910
911 /**
912 * 行パケットを解析する(テキストプロトコル)
913 *
914 * 結果セットの行データを解析して返します。数値の列はint・floatにします。
915 *
916 * @param string $packet 行パケット
917 * @param array $fields フィールドメタデータの配列
918 * @return array 行データの連想配列
919 */
920 private function parseRowPacket(string $packet, array $fields): array {
921 $pos = 0;
922 $row = [];
923
924 foreach ($fields as $field) {
925 $value = $this->readLengthEncodedString($packet, $pos); // 0xFB = NULL
926 $row[$field['name']] = $value === null ? null : $this->castText($value, $field);
927 }
928
929 return $row;
930 }
931
932 /**
933 * 行パケットを解析する(バイナリプロトコル)
934 *
935 * 0x00、NULLビットマップ(2ビットずらし)、値の順。
936 *
937 * @param string $packet 行パケット
938 * @param array $fields フィールドメタデータの配列
939 * @return array 行データの連想配列
940 */
941 private function parseBinaryRowPacket(string $packet, array $fields): array {
942 $cnt = count($fields);
943 $bitmapLen = intdiv($cnt + 7 + 2, 8);
944 $bitmap = substr($packet, 1, $bitmapLen);
945 $pos = 1 + $bitmapLen;
946 $row = [];
947
948 foreach ($fields as $i => $field) {
949 $bit = $i + 2;
950 if (ord($bitmap[$bit >> 3]) & (1 << ($bit & 7))) {
951 $row[$field['name']] = null;
952 continue;
953 }
954 $row[$field['name']] = $this->readBinaryValue($packet, $pos, $field);
955 }
956
957 return $row;
958 }
959
960 /**
961 * テキストプロトコルの値をPHPの型にする
962 *
963 * @param string $value 値
964 * @param array $field フィールドメタデータ
965 * @return mixed 整数の列はint(PHP_INT_MAXを超えたら文字列のまま)、FLOAT・DOUBLEはfloat、その他は文字列
966 */
967 private function castText(string $value, array $field): mixed {
968 switch ($field['type']) {
969 case self::TYPE_TINY:
970 case self::TYPE_SHORT:
971 case self::TYPE_LONG:
972 case self::TYPE_INT24:
973 case self::TYPE_LONGLONG:
974 case self::TYPE_YEAR:
975 $int = (int)$value;
976 return (string)$int === $value ? $int : $value;
977 case self::TYPE_FLOAT:
978 case self::TYPE_DOUBLE:
979 return (float)$value;
980 }
981
982 return $value;
983 }
984
985 /**
986 * バイナリプロトコルの値を1つ読む
987 *
988 * @param string $packet 行パケット
989 * @param int $pos 位置(進める)
990 * @param array $field フィールドメタデータ
991 * @return mixed
992 */
993 private function readBinaryValue(string $packet, int &$pos, array $field): mixed {
994 $unsigned = ($field['flags'] & self::UNSIGNED_FLAG) !== 0;
995
996 switch ($field['type']) {
997 case self::TYPE_TINY:
998 $v = ord($packet[$pos]);
999 $pos += 1;
1000 return $unsigned || $v < 0x80 ? $v : $v - 0x100;
1001 case self::TYPE_SHORT:
1002 case self::TYPE_YEAR:
1003 $v = unpack('v', $packet, $pos)[1];
1004 $pos += 2;
1005 return $unsigned || $v < 0x8000 ? $v : $v - 0x10000;
1006 case self::TYPE_LONG:
1007 case self::TYPE_INT24:
1008 $v = unpack('V', $packet, $pos)[1];
1009 $pos += 4;
1010 return $unsigned || $v < 0x80000000 ? $v : $v - 0x100000000;
1011 case self::TYPE_LONGLONG:
1012 $v = unpack('P', $packet, $pos)[1];
1013 $pos += 8;
1014 return $unsigned && $v < 0 ? sprintf('%u', $v) : $v;
1015 case self::TYPE_FLOAT:
1016 $v = unpack('g', $packet, $pos)[1];
1017 $pos += 4;
1018 return (float)sprintf('%.6g', $v); // テキストプロトコルと同じ精度
1019 case self::TYPE_DOUBLE:
1020 $v = unpack('e', $packet, $pos)[1];
1021 $pos += 8;
1022 return $v;
1023 case self::TYPE_DATE:
1024 case self::TYPE_DATETIME:
1025 case self::TYPE_TIMESTAMP:
1026 return $this->readBinaryDateTime($packet, $pos, $field);
1027 case self::TYPE_TIME:
1028 return $this->readBinaryTime($packet, $pos, $field);
1029 case self::TYPE_NULL:
1030 return null;
1031 }
1032
1033 // DECIMAL、文字列、BLOB、BIT、ENUM、SET、JSON、GEOMETRY等
1034 return $this->readLengthEncodedString($packet, $pos);
1035 }
1036
1037 /**
1038 * バイナリのDATE・DATETIME・TIMESTAMPを読む
1039 *
1040 * 長さ(0、4、7、11)、年(2)、月、日、時、分、秒、マイクロ秒(4)。
1041 *
1042 * @return string 「Y-m-d」又は「Y-m-d H:i:s[.u]」
1043 */
1044 private function readBinaryDateTime(string $packet, int &$pos, array $field): string {
1045 $len = ord($packet[$pos]);
1046 $pos++;
1047 $b = substr($packet, $pos, $len)."\0\0\0\0\0\0\0\0\0\0\0";
1048 $pos += $len;
1049
1050 $date = sprintf('%04d-%02d-%02d', unpack('v', $b)[1], ord($b[2]), ord($b[3]));
1051 if ($field['type'] === self::TYPE_DATE) return $date;
1052
1053 return $date.sprintf(' %02d:%02d:%02d', ord($b[4]), ord($b[5]), ord($b[6]))
1054 .$this->fraction(unpack('V', $b, 7)[1], $field['decimals']);
1055 }
1056
1057 /**
1058 * バイナリのTIMEを読む
1059 *
1060 * 長さ(0、8、12)、負数(1)、日(4)、時、分、秒、マイクロ秒(4)。
1061 *
1062 * @return string 「[-]H:i:s[.u]」(時は24以上になる事がある)
1063 */
1064 private function readBinaryTime(string $packet, int &$pos, array $field): string {
1065 $len = ord($packet[$pos]);
1066 $pos++;
1067 $b = substr($packet, $pos, $len)."\0\0\0\0\0\0\0\0\0\0\0\0";
1068 $pos += $len;
1069
1070 $hours = unpack('V', $b, 1)[1] * 24 + ord($b[5]);
1071 return (ord($b[0]) ? '-' : '').sprintf('%02d:%02d:%02d', $hours, ord($b[6]), ord($b[7]))
1072 .$this->fraction(unpack('V', $b, 8)[1], $field['decimals']);
1073 }
1074
1075 /**
1076 * 秒の小数部分(列の桁数に合わせる)
1077 *
1078 * @param int $usec マイクロ秒
1079 * @param int $decimals 列の小数点以下の桁数(0~6、31=不明)
1080 * @return string
1081 */
1082 private function fraction(int $usec, int $decimals): string {
1083 if ($decimals > 0 && $decimals <= 6) return '.'.substr(sprintf('%06d', $usec), 0, $decimals);
1084 if ($decimals > 6 && $usec > 0) return '.'.sprintf('%06d', $usec);
1085 return '';
1086 }
1087
1088 /**
1089 * OKパケットを解析する
1090 *
1091 * OKパケットの内容を解析して影響を受けた行数や挿入IDなどを返します。
1092 *
1093 * @param string $packet OKパケット
1094 * @return array affectedRows、insertId、serverStatus、warningCount、info
1095 * @throws DbException パケットが不完全な場合
1096 */
1097 private function parseOkPacket(string $packet): array {
1098 if (strlen($packet) < 7) {
1099 $this->fail("OKパケットが短すぎます: ".strlen($packet)."バイト");
1100 }
1101
1102 $pos = 1; // ヘッダーバイト(0x00)をスキップする
1103 $affectedRows = (int)$this->readLengthEncodedInteger($packet, $pos);
1104 $insertId = (int)$this->readLengthEncodedInteger($packet, $pos);
1105 $serverStatus = unpack('v', $packet, $pos)[1];
1106 $warningCount = unpack('v', $packet, $pos + 2)[1];
1107 $this->status = $serverStatus;
1108
1109 return [
1110 'affectedRows' => $affectedRows,
1111 'insertId' => $insertId,
1112 'serverStatus' => $serverStatus,
1113 'warningCount' => $warningCount,
1114 'info' => (string)substr($packet, $pos + 4),
1115 ];
1116 }
1117
1118 /**
1119 * EOFパケットを解析する
1120 *
1121 * @param string $packet EOFパケット(0xFE、警告数(2)、サーバーの状況(2))
1122 * @return array warningCount、serverStatus
1123 */
1124 private function parseEofPacket(string $packet): array {
1125 if (strlen($packet) < 5) return ['warningCount' => 0, 'serverStatus' => $this->status];
1126 $eof = unpack('vwarningCount/vserverStatus', $packet, 1);
1127 $this->status = $eof['serverStatus'];
1128 return $eof;
1129 }
1130
1131 /**
1132 * MySQLサーバーからパケットを読み込む
1133 *
1134 * 16MiB以上のパケット(複数のパケットに分かれる)は1つにまとめます。
1135 *
1136 * @return string 受信したパケットデータ
1137 * @throws DbException 読み込みに失敗した場合
1138 */
1139 private function readPacket(): string {
1140 $data = '';
1141
1142 do {
1143 $header = $this->readBytes(4); // パケットのヘッダー=4バイト
1144
1145 // パケットの長さを最初3バイトからパーシングする
1146 $len = unpack('V', substr($header, 0, 3)."\0")[1];
1147
1148 // パケットの順序番号は第4目のバイト
1149 $seqNum = ord($header[3]);
1150 $this->seq = ($seqNum + 1) & 0xFF;
1151
1152 $chunk = $len > 0 ? $this->readBytes($len) : '';
1153 $data .= $chunk;
1154
1155 // デバッグ
1156 if ($this->debug) {
1157 $this->logPacket([
1158 'direction' => 'RECV',
1159 'length' => $len,
1160 'seqNum' => $seqNum,
1161 'data' => $chunk,
1162 'timestamp' => microtime(true),
1163 ]);
1164 }
1165 } while ($len === 0xFFFFFF);
1166
1167 return $data;
1168 }
1169
1170 /**
1171 * ソケットから指定したバイト数を読む
1172 *
1173 * @param int $len バイト数
1174 * @return string
1175 * @throws DbException 接続が切れた、又はタイムアウトした場合
1176 */
1177 private function readBytes(int $len): string {
1178 if (!$this->socket) $this->fail('接続していません');
1179
1180 $buf = '';
1181 while (strlen($buf) < $len) {
1182 $chunk = @fread($this->socket, min($len - strlen($buf), 1048576));
1183 if ($chunk === false || $chunk === '') {
1184 $timedOut = Tls::timedOut($this->socket) || (bool)(stream_get_meta_data($this->socket)['timed_out'] ?? false);
1185 if (!$timedOut && $chunk === '' && !feof($this->socket)) continue;
1186 $this->drop();
1187 $this->fail($timedOut ? "パケットの読み込みがタイムアウト({$this->timeout}秒)" : 'サーバーとの接続が切れました');
1188 }
1189 $buf .= $chunk;
1190 }
1191
1192 return $buf;
1193 }
1194
1195 /**
1196 * MySQLサーバーにパケットを送信する
1197 *
1198 * 16MiB以上のデータは複数のパケットに分けます。
1199 *
1200 * @param string $data 送信するデータ
1201 * @return bool 成功時はtrue
1202 * @throws DbException 送信に失敗した場合
1203 */
1204 private function sendPacket(string $data): bool {
1205 $total = strlen($data);
1206 $pos = 0;
1207
1208 do {
1209 $len = min($total - $pos, 0xFFFFFF);
1210 $chunk = substr($data, $pos, $len);
1211 $pos += $len;
1212
1213 // パケットヘッダー:長さ=3バイト、順序番号=1バイト
1214 $header = substr(pack('V', $len), 0, 3).chr($this->seq);
1215
1216 // デバッグ
1217 if ($this->debug) {
1218 $this->logPacket([
1219 'direction' => 'SEND',
1220 'length' => $len,
1221 'seqNum' => $this->seq,
1222 'data' => $chunk,
1223 'timestamp' => microtime(true),
1224 ]);
1225 }
1226
1227 $this->seq = ($this->seq + 1) & 0xFF;
1228 $this->writeBytes($header.$chunk);
1229 } while ($len === 0xFFFFFF); // ちょうど16MiB-1の倍数なら、空のパケットで終わる
1230
1231 return true;
1232 }
1233
1234 /**
1235 * ソケットに全部書く
1236 *
1237 * @param string $data
1238 * @return void
1239 * @throws DbException
1240 */
1241 private function writeBytes(string $data): void {
1242 if (!$this->socket) $this->fail('接続していません');
1243
1244 $len = strlen($data);
1245 $sent = 0;
1246 while ($sent < $len) {
1247 $n = @fwrite($this->socket, substr($data, $sent, 1048576));
1248 if ($n === false || $n === 0) {
1249 $this->drop();
1250 $this->fail('パケットデータの送信に失敗');
1251 }
1252 $sent += $n;
1253 }
1254 }
1255
1256 /**
1257 * MySQLサーバーにコマンドを送信する
1258 *
1259 * 新しいコマンドなので、順序番号は0から。
1260 *
1261 * @param int $command コマンド(例: 0x03 = COM_QUERY)
1262 * @param string $data 付加データ(デフォルトは空)
1263 * @return bool 成功時はtrue
1264 * @throws DbException 送信に失敗した場合
1265 */
1266 private function sendCommand(int $command, string $data = ''): bool {
1267 $this->seq = 0;
1268 return $this->sendPacket(chr($command).$data);
1269 }
1270
1271 /**
1272 * 接続していなければ例外
1273 *
1274 * @return void
1275 * @throws DbException
1276 */
1277 private function requireConnection(): void {
1278 if (!$this->connected) $this->fail('接続していません(先にconnect()を呼んで下さい)');
1279 }
1280
1281 /**
1282 * ソケットを閉じ、状態を戻す(COM_QUITを送らない)
1283 *
1284 * @return void
1285 */
1286 private function drop(): void {
1287 if ($this->socket) @fclose($this->socket);
1288 $this->socket = null;
1289 $this->connected = false;
1290 $this->secure = false;
1291 $this->status = 0;
1292 $this->prepared = [];
1293 }
1294
1295 /**
1296 * ログに書いて例外を投げる
1297 *
1298 * @param string $msg メッセージ
1299 * @param int $code MySQLのエラーコード
1300 * @param string $sqlState SQLSTATE
1301 * @return never
1302 * @throws DbException
1303 */
1304 private function fail(string $msg, int $code = 0, string $sqlState = ''): never {
1305 if (function_exists('logger')) logger(\LogType::Db, $msg);
1306 throw new DbException($msg, $code, $sqlState);
1307 }
1308
1309 /**
1310 * エラーパケット(0xFF、コード(2)、「#」、SQLSTATE(5)、メッセージ)から例外を投げる
1311 *
1312 * @param string $what 何に失敗したか
1313 * @param string $packet エラーパケット
1314 * @return never
1315 * @throws DbException
1316 */
1317 private function failPacket(string $what, string $packet): never {
1318 $code = strlen($packet) >= 3 ? unpack('v', $packet, 1)[1] : 0;
1319 $state = '';
1320 $mes = (string)substr($packet, 3);
1321 if (str_starts_with($mes, '#')) {
1322 $state = substr($mes, 1, 5);
1323 $mes = (string)substr($mes, 6);
1324 }
1325 $this->fail("{$what}: {$code} - {$mes}", $code, $state);
1326 }
1327
1328 /**
1329 * サーバーの挨拶パケットを解析する
1330 *
1331 * サーバーからの初期挨拶パケット(Protocol::HandshakeV10)を解析し、サーバー情報を保存します。
1332 *
1333 * @param string $packet 挨拶パケット
1334 * @return void
1335 * @throws DbException エラー(接続の拒否等)又は未対応のプロトコルの場合
1336 */
1337 private function parseServerGreeting(string $packet): void {
1338 if (ord($packet[0] ?? "\0") === 0xFF) {
1339 $this->failPacket('接続を拒否されました', $packet);
1340 }
1341
1342 $pos = 0;
1343
1344 // プロトコールバージョン(1バイト)
1345 $this->serverInfo['protocol'] = ord($packet[$pos]);
1346 $pos++;
1347 if ($this->serverInfo['protocol'] !== 10) {
1348 $this->fail("未対応のプロトコル: {$this->serverInfo['protocol']}");
1349 }
1350
1351 // サーバーバージョン
1352 $end = strpos($packet, "\0", $pos);
1353 $this->serverInfo['version'] = substr($packet, $pos, $end - $pos);
1354 $pos = $end + 1;
1355
1356 // スレッドID(4バイト)
1357 $this->serverInfo['threadId'] = unpack('V', $packet, $pos)[1];
1358 $pos += 4;
1359
1360 // スクランブルバッファの最初の部分(8バイト)
1361 $scramble = substr($packet, $pos, 8);
1362 $pos += 8;
1363
1364 // フィルターバイトをスキップする
1365 $pos++;
1366
1367 // サーバー機能の下位(2バイト)
1368 $caps = unpack('v', $packet, $pos)[1];
1369 $pos += 2;
1370
1371 $this->serverInfo['charset'] = 0;
1372 $this->serverInfo['status'] = 0;
1373 $this->serverInfo['plugin'] = '';
1374
1375 if ($pos < strlen($packet)) {
1376 // サーバー言語(1バイト)
1377 $this->serverInfo['charset'] = ord($packet[$pos]);
1378 $pos++;
1379
1380 // サーバー状況(2バイト)
1381 $this->serverInfo['status'] = unpack('v', $packet, $pos)[1];
1382 $pos += 2;
1383
1384 // サーバー機能の上位(2バイト)
1385 $caps |= unpack('v', $packet, $pos)[1] << 16;
1386 $pos += 2;
1387
1388 // スクランブルの長さ(1バイト)と予約(10バイト)
1389 $authLen = ord($packet[$pos]);
1390 $pos += 11;
1391
1392 // スクランブルの残り(最後の\0を除く)
1393 if ($caps & self::CLIENT_SECURE_CONNECTION) {
1394 $len = max(13, $authLen - 8);
1395 $scramble .= rtrim(substr($packet, $pos, $len), "\0");
1396 $pos += $len;
1397 }
1398
1399 // 認証プラグイン名
1400 if ($caps & self::CLIENT_PLUGIN_AUTH) {
1401 $end = strpos($packet, "\0", $pos);
1402 $this->serverInfo['plugin'] = substr($packet, $pos, ($end === false ? strlen($packet) : $end) - $pos);
1403 }
1404 }
1405
1406 $this->serverInfo['capabilities'] = $caps;
1407 $this->serverInfo['scramble'] = $scramble;
1408 $this->status = $this->serverInfo['status'];
1409 }
1410
1411 /**
1412 * パスワードをスクランブルする(mysql_native_password)
1413 *
1414 * SHA1(password) XOR SHA1(scramble + SHA1(SHA1(password)))
1415 *
1416 * @param string $password プレーンテキストのパスワード
1417 * @param string $scramble サーバーから提供されたスクランブル文字列
1418 * @return string スクランブルされたパスワード(20バイト)
1419 */
1420 private function scramblePassword(string $password, string $scramble): string {
1421 $stage1 = sha1($password, true);
1422 $stage2 = sha1($stage1, true);
1423 $stage3 = sha1($scramble.$stage2, true);
1424
1425 return $stage1 ^ $stage3;
1426 }
1427
1428 /**
1429 * パスワードをスクランブルする(caching_sha2_password)
1430 *
1431 * SHA256(password) XOR SHA256(SHA256(SHA256(password)) + scramble)
1432 *
1433 * @param string $password プレーンテキストのパスワード
1434 * @param string $scramble サーバーから提供されたスクランブル文字列
1435 * @return string スクランブルされたパスワード(32バイト)
1436 */
1437 private function scrambleSha256(string $password, string $scramble): string {
1438 $stage1 = hash('sha256', $password, true);
1439 $stage2 = hash('sha256', $stage1, true);
1440 $stage3 = hash('sha256', $stage2.$scramble, true);
1441
1442 return $stage1 ^ $stage3;
1443 }
1444
1445 /**
1446 * デバッグのためにパケットをログする
1447 *
1448 * パケット情報をログに追加し、デバッグ出力を表示します。
1449 *
1450 * @param array $packetInfo パケット情報(方向、長さ、シーケンス番号、データ、タイムスタンプ)
1451 * @return void
1452 */
1453 private function logPacket(array $packetInfo): void {
1454 $this->packetLog[] = $packetInfo;
1455
1456 $direction = $packetInfo['direction'];
1457 $length = $packetInfo['length'];
1458 $seqNum = $packetInfo['seqNum'];
1459 $data = $packetInfo['data'];
1460
1461 echo "=== {$direction} パケット (長さ: {$length}, シーケンス: $seqNum) ===\n";
1462 echo $this->hexDumpWithAscii(substr($data, 0, 4096))."\n";
1463
1464 $this->interpretPacket($data);
1465
1466 echo "==========================================\n\n";
1467 }
1468
1469 /**
1470 * バイナリデータを16進数で出力する
1471 *
1472 * デバッグ用にデータを16進数形式で表示します。
1473 *
1474 * @param string $data バイナリデータ
1475 * @return string 16進数文字列
1476 */
1477 private function hexDump(string $data): string {
1478 $res = '';
1479 $len = strlen($data);
1480
1481 for ($i = 0; $i < $len; $i++) {
1482 $res .= sprintf('%02X ', ord($data[$i]));
1483
1484 // 読み易さの為、各16バイトで新行列を入る
1485 if (($i + 1) % 16 === 0 && $i !== $len - 1) {
1486 $res .= "\n";
1487 }
1488 }
1489
1490 return $res;
1491 }
1492
1493 /**
1494 * バイナリデータをASCIIで出力する
1495 *
1496 * デバッグ用にデータをASCII形式で表示します。
1497 *
1498 * @param string $data バイナリデータ
1499 * @return string ASCII文字列
1500 */
1501 private function asciiDump(string $data): string {
1502 $res = '';
1503 $len = strlen($data);
1504
1505 for ($i = 0; $i < $len; $i++) {
1506 $char = ord($data[$i]);
1507
1508 // 表示出来るASCII文字だけを書き出す
1509 if ($char >= 32 && $char <= 126) {
1510 $res .= $data[$i];
1511 } else {
1512 $res .= '.';
1513 }
1514
1515 // 読み易さの為、各16バイトで新行列を入る
1516 if (($i + 1) % 16 === 0 && $i !== $len - 1) {
1517 $res .= "\n";
1518 }
1519 }
1520
1521 return $res;
1522 }
1523
1524 /**
1525 * バイナリデータを2進数で出力する
1526 *
1527 * デバッグ用にデータを2進数形式で表示します。
1528 *
1529 * @param string $data バイナリデータ
1530 * @return string 2進数文字列
1531 */
1532 private function binaryDump(string $data): string {
1533 $res = '';
1534 $len = strlen($data);
1535
1536 for ($i = 0; $i < $len; $i++) {
1537 $res .= sprintf('%08b ', ord($data[$i]));
1538
1539 // 読み易さの為、各8バイトで新行列を入る
1540 if (($i + 1) % 8 === 0 && $i !== $len - 1) {
1541 $res .= "\n";
1542 }
1543 }
1544
1545 return $res;
1546 }
1547
1548 /**
1549 * パケットを最初のバイトに基づいて解釈する
1550 *
1551 * パケットの種類を特定し、デバッグ情報を出力します。
1552 *
1553 * @param string $data パケットデータ
1554 * @return void
1555 */
1556 private function interpretPacket(string $data): void {
1557 if ($data === '') {
1558 echo "解釈: 空パケット\n";
1559 return;
1560 }
1561
1562 $firstByte = ord($data[0]);
1563
1564 switch ($firstByte) {
1565 case 0x00:
1566 echo "解釈: OKパケット\n";
1567 $this->debugOkPacket($data);
1568 break;
1569 case 0x17:
1570 echo "解釈: COM_STMT_EXECUTEパケット\n";
1571 $this->debugStmtExecutePacket($data);
1572 break;
1573 case 0xFF:
1574 echo "解釈: エラーパケット\n";
1575 $this->debugErrorPacket($data);
1576 break;
1577 case 0xFE:
1578 echo "解釈: EOFパケット\n";
1579 break;
1580 case 0xFB:
1581 echo "解釈: LOCAL INFILEリクエスト\n";
1582 break;
1583 default:
1584 if ($firstByte === 3 && substr($data, 1, 3) === 'def') {
1585 // フィールドパケットかどうかの確認
1586 echo "解釈: フィールド説明パケット\n";
1587 $this->debugFieldPacket($data);
1588 } else if ($firstByte > 0 && $firstByte < 251 && strlen($data) <= 9) {
1589 // 結果セットパケットかどうかの確認
1590 echo "解釈: 結果セットヘッダーパケット(フィールド数: {$firstByte})\n";
1591 } else {
1592 // 以上じゃないと、列データパケットでかもしん
1593 echo "解釈: 列データパケット又はその他のパケット種類\n";
1594 $this->debugLengthEncodedStrings($data);
1595 }
1596 break;
1597 }
1598 }
1599
1600 /**
1601 * OKパケット構造をデバッグする
1602 *
1603 * OKパケットの内容を解析し、デバッグ情報を出力します。
1604 *
1605 * @param string $data OKパケットデータ
1606 * @return void
1607 */
1608 private function debugOkPacket(string $data): void {
1609 if (strlen($data) < 2) {
1610 echo ' エラー: OKパケットが短すぎます ('.strlen($data)."バイト)\n";
1611 return;
1612 }
1613
1614 $pos = 1; // ヘッダーバイトをスキップ
1615
1616 // 影響を受けた行数を取得
1617 $affectedRows = $this->readLengthEncodedInteger($data, $pos);
1618 echo " 影響を受けた行数: {$affectedRows}\n";
1619
1620 // 最後の挿入IDを取得
1621 $insertId = $this->readLengthEncodedInteger($data, $pos);
1622 echo " 最後の挿入ID: {$insertId}\n";
1623
1624 // サーバーステータス
1625 if (strlen($data) >= $pos + 2) {
1626 $serverStatus = unpack('v', $data, $pos)[1];
1627 echo " サーバーステータス: ".sprintf('0x%04X', $serverStatus)."\n";
1628 $pos += 2;
1629 } else {
1630 echo " サーバーステータス: 利用不可\n";
1631 }
1632
1633 // 警告カウント
1634 if (strlen($data) >= $pos + 2) {
1635 $warningCount = unpack('v', $data, $pos)[1];
1636 echo " 警告カウント: {$warningCount}\n";
1637 $pos += 2;
1638 } else {
1639 echo " 警告カウント: 利用不可\n";
1640 }
1641
1642 // サーバーメッセージ(存在する場合)
1643 if (strlen($data) > $pos) {
1644 $message = substr($data, $pos);
1645 echo " メッセージ: ".$this->safeString($message)."\n";
1646 }
1647 }
1648
1649 /**
1650 * エラーパケット構造をデバッグする
1651 *
1652 * エラーパケットの内容を解析し、デバッグ情報を出力します。
1653 *
1654 * @param string $data エラーパケットデータ
1655 * @return void
1656 */
1657 private function debugErrorPacket(string $data): void {
1658 $errorCode = unpack('v', substr($data, 1, 2)."\0\0")[1];
1659 echo " エラーコード: {$errorCode}\n";
1660
1661 // SQLステートマーカーをスキップ(#)
1662 $sqlState = substr($data, 4, 5);
1663 echo " SQLステート: {$sqlState}\n";
1664
1665 $errorMessage = substr($data, 9);
1666 echo " エラーメッセージ: ".$this->safeString($errorMessage)."\n";
1667 }
1668
1669 /**
1670 * フィールドパケット構造をデバッグする
1671 *
1672 * フィールドパケットの内容を解析し、デバッグ情報を出力します。
1673 *
1674 * @param string $data フィールドパケットデータ
1675 * @return void
1676 */
1677 private function debugFieldPacket(string $data): void {
1678 $field = $this->parseFieldPacket($data);
1679
1680 echo " フィールドパケット構造:\n";
1681 echo " カタログ: ".$this->safeString($field['catalog'])."\n";
1682 echo " データベース: ".$this->safeString($field['db'])."\n";
1683 echo " テーブル: ".$this->safeString($field['table'])."\n";
1684 echo " 元のテーブル: ".$this->safeString($field['org_table'])."\n";
1685 echo " 名前: ".$this->safeString($field['name'])."\n";
1686 echo " 元の名前: ".$this->safeString($field['org_name'])."\n";
1687 echo " 文字セット: ".sprintf('0x%04X', $field['charset'])."\n";
1688 echo " 列の長さ: {$field['length']}\n";
1689 echo " 列の種類: ".sprintf('0x%02X', $field['type'])." (".$this->getMysqlTypeName($field['type']).")\n";
1690 echo " フラグ: ".sprintf('0x%04X', $field['flags'])."\n";
1691 echo " 小数点以下の桁数: {$field['decimals']}\n";
1692 }
1693
1694 /**
1695 * パケット内の長さエンコード文字列をデバッグする
1696 *
1697 * パケットからすべての長さエンコード文字列を抽出し、デバッグ情報を出力します。
1698 *
1699 * @param string $data パケットデータ
1700 * @return void
1701 */
1702 private function debugLengthEncodedStrings(string $data): void {
1703 $pos = 0;
1704 $length = strlen($data);
1705 $stringCount = 0;
1706
1707 echo " 長さエンコード文字列:\n";
1708
1709 while ($pos < $length) {
1710 $firstByte = ord($data[$pos]);
1711 if ($firstByte === 0xFF) break;
1712
1713 $start = $pos;
1714 $value = $this->readLengthEncodedString($data, $pos);
1715 if ($pos > $length) {
1716 echo " 位置 {$start} での無効な長さエンコーディング\n";
1717 break;
1718 }
1719
1720 if ($value === null) {
1721 echo " 文字列 ".(++$stringCount).": NULL\n";
1722 } else {
1723 echo " 文字列 ".(++$stringCount).": ".$this->safeString(substr($value, 0, 256))
1724 ." (長さ: ".strlen($value).")\n";
1725 }
1726 }
1727
1728 if ($stringCount === 0) {
1729 echo " 長さエンコード文字列はありません\n";
1730 }
1731 }
1732
1733 /**
1734 * 文字列を安全に出力用に変換する
1735 *
1736 * 表示可能な文字のみを含み、非表示文字は16進数で表現します。
1737 *
1738 * @param string $str 変換する文字列
1739 * @return string 安全な文字列
1740 */
1741 private function safeString(string $str): string {
1742 $result = '';
1743 $length = strlen($str);
1744
1745 for ($i = 0; $i < $length; $i++) {
1746 $char = ord($str[$i]);
1747 if ($char >= 32 && $char <= 126) {
1748 $result .= $str[$i];
1749 } else {
1750 $result .= '\\x'.sprintf('%02X', $char);
1751 }
1752 }
1753
1754 return $result;
1755 }
1756
1757 /**
1758 * 長さエンコード整数を読む
1759 *
1760 * 0xFB=NULL、0xFC=2バイト、0xFD=3バイト、0xFE=8バイト、それ以外=その値。
1761 *
1762 * @param string $data パケットデータ
1763 * @param int $pos 開始位置(読んだ分だけ進める)
1764 * @return int|null 整数値、NULLの場合はnull
1765 */
1766 private function readLengthEncodedInteger(string $data, int &$pos): ?int {
1767 $firstByte = ord($data[$pos] ?? "\0");
1768 $pos++;
1769
1770 if ($firstByte < 0xFB) return $firstByte;
1771 if ($firstByte === 0xFB) return null;
1772
1773 $size = [0xFC => 2, 0xFD => 3, 0xFE => 8][$firstByte] ?? 0;
1774 $bytes = str_pad(substr($data, $pos, $size), 8, "\0");
1775 $pos += $size;
1776 return unpack('P', $bytes)[1];
1777 }
1778
1779 /**
1780 * 長さエンコード文字列を読む
1781 *
1782 * @param string $data パケットデータ
1783 * @param int $pos 開始位置(読んだ分だけ進める)
1784 * @return string|null 文字列、NULLの場合はnull
1785 */
1786 private function readLengthEncodedString(string $data, int &$pos): ?string {
1787 $len = $this->readLengthEncodedInteger($data, $pos);
1788 if ($len === null) return null;
1789
1790 $value = (string)substr($data, $pos, $len);
1791 $pos += $len;
1792 return $value;
1793 }
1794
1795 /**
1796 * 整数を長さエンコード形式に変換する
1797 *
1798 * MySQLプロトコルに基づいて整数を長さエンコードします。
1799 *
1800 * @param int $value 変換する整数
1801 * @return string 長さエンコードされた文字列
1802 */
1803 private function encodeLengthEncodedInteger(int $value): string {
1804 if ($value < 251) {
1805 return chr($value);
1806 } else if ($value < 65536) {
1807 return chr(0xFC).pack('v', $value);
1808 } else if ($value < 16777216) {
1809 return chr(0xFD).substr(pack('V', $value), 0, 3);
1810 } else {
1811 return chr(0xFE).pack('P', $value);
1812 }
1813 }
1814
1815 /**
1816 * STMTパケットをデバッグする
1817 *
1818 * COM_STMT_EXECUTEパケットの内容を解析し、デバッグ情報を出力します。
1819 *
1820 * @param string $data パケットデータ
1821 * @return void
1822 */
1823 private function debugStmtExecutePacket(string $data): void {
1824 if (strlen($data) < 10) {
1825 echo " エラー: パケットが短すぎます\n";
1826 return;
1827 }
1828
1829 $pos = 1; // コマンドバイトをスキップ
1830 $statementId = unpack('V', $data, $pos)[1];
1831 echo " ステートメントID: {$statementId}\n";
1832 $pos += 5; // ステートメントIDとカーソルフラグをスキップ
1833 $iterationCount = unpack('V', $data, $pos)[1];
1834 echo " 繰り返し数: {$iterationCount}\n";
1835 $pos += 4;
1836
1837 $numParams = $this->prepared[$statementId]['num_params'] ?? 0;
1838 if ($numParams > 0) {
1839 $nullBitmapLen = intdiv($numParams + 7, 8);
1840 $nullBitmap = substr($data, $pos, $nullBitmapLen);
1841 echo " NULLビットマップ: ".$this->hexDump($nullBitmap)."\n";
1842 $pos += $nullBitmapLen;
1843
1844 $newParamsFlag = ord($data[$pos]);
1845 echo " 新パラメータフラグ: {$newParamsFlag}\n";
1846 $pos++;
1847
1848 if ($newParamsFlag) {
1849 echo " パラメータ種類:\n";
1850
1851 for ($i = 0; $i < $numParams; $i++) {
1852 $type = ord($data[$pos]);
1853 $typeName = $this->getMysqlTypeName($type);
1854 echo " パラメータ {$i}: ".sprintf("0x%02X", $type)." ({$typeName})\n";
1855 $pos += 2;
1856 }
1857
1858 echo " パラメータ値: (生バイナリが続く)\n";
1859 }
1860 }
1861 }
1862
1863 /**
1864 * 16進値をMySQLタイプ名に変換する
1865 *
1866 * MySQLのデータ型コードを対応する型名に変換します。
1867 *
1868 * @param int $type 16進数の型コード
1869 * @return string 型名(不明な場合は'不明')
1870 */
1871 private function getMysqlTypeName(int $type): string {
1872 $types = [
1873 0x00 => 'MYSQL_TYPE_DECIMAL',
1874 0x01 => 'MYSQL_TYPE_TINY',
1875 0x02 => 'MYSQL_TYPE_SHORT',
1876 0x03 => 'MYSQL_TYPE_LONG',
1877 0x04 => 'MYSQL_TYPE_FLOAT',
1878 0x05 => 'MYSQL_TYPE_DOUBLE',
1879 0x06 => 'MYSQL_TYPE_NULL',
1880 0x07 => 'MYSQL_TYPE_TIMESTAMP',
1881 0x08 => 'MYSQL_TYPE_LONGLONG',
1882 0x09 => 'MYSQL_TYPE_INT24',
1883 0x0A => 'MYSQL_TYPE_DATE',
1884 0x0B => 'MYSQL_TYPE_TIME',
1885 0x0C => 'MYSQL_TYPE_DATETIME',
1886 0x0D => 'MYSQL_TYPE_YEAR',
1887 0x0E => 'MYSQL_TYPE_NEWDATE',
1888 0x0F => 'MYSQL_TYPE_VARCHAR',
1889 0x10 => 'MYSQL_TYPE_BIT',
1890 0xF5 => 'MYSQL_TYPE_JSON',
1891 0xF6 => 'MYSQL_TYPE_NEWDECIMAL',
1892 0xF7 => 'MYSQL_TYPE_ENUM',
1893 0xF8 => 'MYSQL_TYPE_SET',
1894 0xF9 => 'MYSQL_TYPE_TINY_BLOB',
1895 0xFA => 'MYSQL_TYPE_MEDIUM_BLOB',
1896 0xFB => 'MYSQL_TYPE_LONG_BLOB',
1897 0xFC => 'MYSQL_TYPE_BLOB',
1898 0xFD => 'MYSQL_TYPE_VAR_STRING',
1899 0xFE => 'MYSQL_TYPE_STRING',
1900 0xFF => 'MYSQL_TYPE_GEOMETRY',
1901 ];
1902
1903 return $types[$type] ?? '不明';
1904 }
1905
1906 /**
1907 * 16進数とASCII値を横に並べて出力する
1908 *
1909 * 16進エディタのように、16進数とASCII値を並べて表示します。
1910 *
1911 * @param string $data バイナリデータ
1912 * @return string フォーマットされた文字列
1913 */
1914 private function hexDumpWithAscii(string $data): string {
1915 $output = '';
1916 $len = strlen($data);
1917 $offset = 0;
1918
1919 while ($offset < $len) {
1920 $hex = '';
1921 $ascii = '';
1922 $bytesInLine = min(16, $len - $offset); // 1行あたり16バイト
1923
1924 // 16進数部分
1925 for ($i = 0; $i < 16; $i++) {
1926 if ($i < $bytesInLine) {
1927 $hex .= sprintf('%02X ', ord($data[$offset + $i]));
1928 } else {
1929 $hex .= ' '; // 揃えのためにスペースを埋める
1930 }
1931 }
1932
1933 // ASCII部分
1934 for ($i = 0; $i < $bytesInLine; $i++) {
1935 $char = ord($data[$offset + $i]);
1936 $ascii .= ($char >= 32 && $char <= 126) ? chr($char) : '.';
1937 }
1938
1939 $output .= sprintf("%08X %s |%s|\n", $offset, $hex, $ascii);
1940 $offset += 16;
1941 }
1942
1943 return $output;
1944 }
1945}