[]' ): only the verb and the * table name reach the job error and log, never statement text (it could hold row data such as a password hash). * * AI1WM writes the table prefix as SERVMASK_PREFIX_: in table names it maps to the archive prefix, and so does a * string value that starts with it (AI1WM rewrites option and usermeta keys such as SERVMASK_PREFIX_user_roles). * * The cursor is {offset}: the byte offset of the next statement start. * * @package Mench_Migrate */ defined( 'ABSPATH' ) || exit; // phpcs:disable WordPress.WP.AlternativeFunctions -- Streams backups, uploads and job files (often several GB) chunk by chunk inside the plugin's own storage folder and the site being restored; WP_Filesystem has no streaming or locking API. // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are internal machine codes; screens and REST answers show only the escaped sentence from Mench_Migrate_Rest::message(). class Mench_Migrate_Sql_Dump_Parser { public const PLACEHOLDER = 'SERVMASK_PREFIX_'; /** Bytes read per chunk. */ public const CHUNK = 1048576; /** Largest single statement accepted. */ private const MAX_STATEMENT = 67108864; private const STEP = 'import-db'; /** Words that open an index or constraint clause in a CREATE TABLE body (anything else is a column). */ private const NON_COLUMNS = [ 'PRIMARY', 'KEY', 'INDEX', 'UNIQUE', 'CONSTRAINT', 'FULLTEXT', 'SPATIAL', 'FOREIGN', 'CHECK' ]; /** @var resource */ private $fh; private string $prefix; /** Bytes read from $base on. */ private string $buf = ''; /** File offset of $buf[0]. */ private int $base = 0; /** Position in $buf of the next statement start. */ private int $pos = 0; private bool $eof = false; /** Escape table for single-quoted strings, built once. */ private static ?array $unescape = null; public function __construct( string $file, string $prefix ) { if ( ! preg_match( '/^[A-Za-z0-9_]{1,40}$/', $prefix ) ) { throw new Mench_Migrate_Db_Exception( 'bad_prefix', self::STEP ); } $fh = is_file( $file ) && is_readable( $file ) ? @fopen( $file, 'rb' ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( false === $fh ) { throw new Mench_Migrate_Db_Exception( 'dump_unreadable', self::STEP ); } $this->fh = $fh; $this->prefix = $prefix; } public function __destruct() { if ( is_resource( $this->fh ) ) { fclose( $this->fh ); } } /** Size of the dump file in bytes. */ public function size(): int { return (int) fstat( $this->fh )['size']; } /** * The next accepted statement at or after $cursor (null: where the previous call stopped, or the start). * * @param array{offset:int}|null $cursor * @return array{type:string, table:string, sql?:string, columns?:?array, rows?:array>, cursor:array{offset:int}}|null Null at the end of the dump. * @throws Mench_Migrate_Db_Exception sql_statement_rejected and friends. */ public function next( ?array $cursor = null ): ?array { if ( null !== $cursor ) { $offset = $cursor['offset'] ?? null; if ( ! is_int( $offset ) || $offset < 0 || $offset > $this->size() ) { throw new Mench_Migrate_Db_Exception( 'bad_cursor', self::STEP ); } if ( $offset !== $this->base + $this->pos ) { $this->seek( $offset ); } } while ( true ) { $sql = $this->statement(); if ( null === $sql ) { return null; } $out = $this->classify( $sql ); if ( null !== $out ) { $out['cursor'] = [ 'offset' => $this->base + $this->pos ]; return $out; } } } // --- splitting -------------------------------------------------------------------------------------------------- private function seek( int $offset ): void { fseek( $this->fh, $offset ); $this->buf = ''; $this->base = $offset; $this->pos = 0; $this->eof = false; } /** Reads one more chunk into the buffer; false at the end of the file. */ private function fill(): bool { if ( $this->eof ) { return false; } $data = fread( $this->fh, self::CHUNK ); if ( false === $data || '' === $data ) { $this->eof = true; return false; } $this->buf .= $data; return true; } /** * The next statement's text (without its `;`), past whitespace and -- comments, or null at the end of the dump. * Afterwards $this->pos is just past the `;`. */ private function statement(): ?string { // Drop what is consumed once it passes a chunk, so the buffer stays about one statement plus one chunk. if ( $this->pos > self::CHUNK ) { $this->buf = substr( $this->buf, $this->pos ); $this->base += $this->pos; $this->pos = 0; } // Leading whitespace and -- comment lines. while ( true ) { $len = strlen( $this->buf ); $this->pos += strspn( $this->buf, " \t\r\n", $this->pos ); if ( $this->pos + 2 >= $len && ! $this->eof && $this->fill() ) { continue; } if ( $this->pos >= strlen( $this->buf ) ) { return null; } if ( '--' === substr( $this->buf, $this->pos, 2 ) && ( $this->pos + 2 >= strlen( $this->buf ) || false !== strpos( " \t\r\n", $this->buf[ $this->pos + 2 ] ) ) ) { $nl = strpos( $this->buf, "\n", $this->pos ); while ( false === $nl && $this->fill() ) { $nl = strpos( $this->buf, "\n", $this->pos ); } $this->pos = false === $nl ? strlen( $this->buf ) : $nl + 1; continue; } break; } $start = $this->pos; $i = $start; $state = ''; // '', "'", '"', '`' or '*' (inside /* */). while ( true ) { if ( $i - $start > self::MAX_STATEMENT ) { throw new Mench_Migrate_Db_Exception( 'sql_statement_rejected: statement too large', self::STEP ); } $len = strlen( $this->buf ); if ( '' === $state ) { $i += strcspn( $this->buf, "'\"`;/", $i ); if ( $i >= $len ) { if ( ! $this->fill() ) { return $this->unterminated( $start ); } continue; } $c = $this->buf[ $i ]; if ( ';' === $c ) { $this->pos = $i + 1; return substr( $this->buf, $start, $i - $start ); } if ( '/' === $c ) { if ( $i + 1 >= $len && $this->fill() ) { continue; // Need the next byte to tell a comment from a slash. } if ( '*' === ( $this->buf[ $i + 1 ] ?? '' ) ) { $state = '*'; $i += 2; } else { $i++; } continue; } $state = $c; $i++; continue; } if ( '*' === $state ) { $end = strpos( $this->buf, '*/', $i ); if ( false === $end ) { $i = max( $i, $len - 1 ); // A '*' at the very end may start the '*/'. if ( ! $this->fill() ) { return $this->unterminated( $start ); } continue; } $state = ''; $i = $end + 2; continue; } // Inside a quoted string or identifier. $stops = '`' === $state ? '`' : '\\' . $state; $i += strcspn( $this->buf, $stops, $i ); if ( $i >= $len ) { if ( ! $this->fill() ) { return $this->unterminated( $start ); } continue; } if ( '\\' === $this->buf[ $i ] ) { if ( $i + 1 >= $len && ! $this->fill() ) { return $this->unterminated( $start ); } $i += 2; continue; } // The quote character: doubled means a literal quote, else the string ends. if ( $i + 1 >= $len && $this->fill() ) { continue; } if ( ( $this->buf[ $i + 1 ] ?? '' ) === $state ) { $i += 2; continue; } $state = ''; $i++; } } private function unterminated( int $start ): never { self::reject( substr( $this->buf, $start, 80 ) . ' (unterminated)' ); } // --- classification --------------------------------------------------------------------------------------------- /** The accepted statement as data, null for one that is skipped; throws for anything else. */ private function classify( string $sql ): ?array { $sql = trim( $sql ); if ( preg_match( '/^(?:START\s+TRANSACTION|COMMIT)$/i', $sql ) || preg_match( '/^SET\s/i', $sql ) ) { return null; } if ( preg_match( '#^/\*!\d{5}\s+SET\s((?!\*/).)*\*/$#is', $sql ) ) { return null; // A versioned session directive, e.g. /*!40101 SET NAMES utf8 */. } if ( preg_match( '/^DROP\s+TABLE\s+IF\s+EXISTS\s+(`[^`]+`|[A-Za-z0-9_$]+)$/i', $sql, $m ) ) { return [ 'type' => 'drop', 'table' => $this->table( $m[1], $sql ) ]; } if ( preg_match( '/^CREATE\s+TABLE\s+(`[^`]+`|[A-Za-z0-9_$]+)\s*\(/i', $sql, $m ) ) { $table = $this->table( $m[1], $sql ); $text = 'CREATE TABLE `' . $table . '` ' . substr( $sql, strlen( $m[0] ) - 1 ); return [ 'type' => 'create', 'table' => $table, 'sql' => $text, 'columns' => self::columns( $text, $sql ) ]; } if ( preg_match( '/^INSERT\s+INTO\s+(`[^`]+`|[A-Za-z0-9_$]+)\s*(\(\s*(?:`[^`]+`|[A-Za-z0-9_$]+)(?:\s*,\s*(?:`[^`]+`|[A-Za-z0-9_$]+))*\s*\))?\s*VALUES\s*(?=\()/i', $sql, $m ) ) { $table = $this->table( $m[1], $sql ); $columns = null; if ( '' !== ( $m[2] ?? '' ) ) { $columns = array_map( static fn( $c ) => trim( trim( $c ), '`' ), explode( ',', trim( $m[2], '() ' ) ) ); } return [ 'type' => 'insert', 'table' => $table, 'columns' => $columns, 'rows' => $this->values( $sql, strlen( $m[0] ), $table ) ]; } self::reject( $sql ); } /** Maps the placeholder prefix and checks the name lies in the archive prefix. */ private function table( string $ident, string $sql ): string { $name = trim( $ident, '`' ); if ( 0 === strpos( $name, self::PLACEHOLDER ) ) { $name = $this->prefix . substr( $name, strlen( self::PLACEHOLDER ) ); } if ( ! Mench_Migrate_Schema::is_valid_name( $name ) || 0 !== strpos( $name, $this->prefix ) || strlen( $name ) === strlen( $this->prefix ) ) { self::reject( $sql ); } return $name; } /** * The tuples of an INSERT from byte $i of $sql on: (v,…),(v,…) and nothing after. * * @return array> */ private function values( string $sql, int $i, string $table ): array { $n = strlen( $sql ); $rows = []; $bad = static fn( int $at ) => self::reject( 'bad value in INSERT INTO `' . $table . '` at byte ' . $at ); while ( true ) { if ( '(' !== ( $sql[ $i ] ?? '' ) ) { $bad( $i ); } $i++; $row = []; while ( true ) { $i += strspn( $sql, " \t\r\n", $i ); $c = $sql[ $i ] ?? ''; if ( "'" === $c ) { $end = self::string_end( $sql, $i + 1 ); if ( null === $end ) { $bad( $i ); } $row[] = $this->unprefix( self::unescape( substr( $sql, $i + 1, $end - $i - 1 ) ) ); $i = $end + 1; } elseif ( preg_match( '/\G(?:0x([0-9A-Fa-f]+)|[Xx]\'([0-9A-Fa-f]*)\')/', $sql, $m, 0, $i ) ) { $hex = '' !== $m[1] ? $m[1] : ( $m[2] ?? '' ); $row[] = (string) hex2bin( 0 === strlen( $hex ) % 2 ? $hex : '0' . $hex ); $i += strlen( $m[0] ); } elseif ( preg_match( '/\G[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?![A-Za-z0-9_.])/', $sql, $m, 0, $i ) ) { $row[] = $m[0]; $i += strlen( $m[0] ); } elseif ( preg_match( '/\GNULL(?![A-Za-z0-9_])/i', $sql, $m, 0, $i ) ) { $row[] = null; $i += 4; } else { $bad( $i ); } $i += strspn( $sql, " \t\r\n", $i ); $c = $sql[ $i ] ?? ''; if ( ',' === $c ) { $i++; continue; } if ( ')' === $c ) { $i++; break; } $bad( $i ); } $rows[] = $row; $i += strspn( $sql, " \t\r\n", $i ); if ( $i >= $n ) { return $rows; } if ( ',' !== $sql[ $i ] ) { $bad( $i ); } $i++; $i += strspn( $sql, " \t\r\n", $i ); } } /** Index of the closing quote of a single-quoted string whose body starts at $i, or null. */ private static function string_end( string $s, int $i ): ?int { $n = strlen( $s ); while ( $i < $n ) { $i += strcspn( $s, "\\'", $i ); if ( $i >= $n ) { return null; } if ( '\\' === $s[ $i ] ) { $i += 2; continue; } if ( "'" === ( $s[ $i + 1 ] ?? '' ) ) { $i += 2; continue; } return $i; } return null; } /** * A string body as MySQL reads it: \0 \' \" \b \n \r \t \Z \\ and '' decode; \% and \_ keep their backslash; any * other backslash is dropped. */ public static function unescape( string $body ): string { if ( false === strpos( $body, '\\' ) && false === strpos( $body, "''" ) ) { return $body; } if ( null === self::$unescape ) { $map = []; for ( $b = 0; $b < 256; $b++ ) { $map[ '\\' . chr( $b ) ] = chr( $b ); } $special = [ '0' => "\0", 'b' => "\x08", 'n' => "\n", 'r' => "\r", 't' => "\t", 'Z' => "\x1a", '%' => '\\%', '_' => '\\_' ]; foreach ( $special as $k => $v ) { $map[ '\\' . $k ] = $v; } $map["''"] = "'"; self::$unescape = $map; } return strtr( $body, self::$unescape ); } private function unprefix( string $v ): string { return 0 === strpos( $v, self::PLACEHOLDER ) ? $this->prefix . substr( $v, strlen( self::PLACEHOLDER ) ) : $v; } /** * Column names of a CREATE TABLE, in order: each top-level clause of the body that does not open with an index or * constraint keyword. * * @return string[] */ private static function columns( string $create, string $original ): array { $open = strpos( $create, '(' ); $n = strlen( $create ); $cols = []; $i = (int) $open + 1; $expect = true; // At the start of a clause. $depth = 1; while ( $i < $n && $depth > 0 ) { $i += strspn( $create, " \t\r\n", $i ); $c = $create[ $i ] ?? ''; if ( $expect ) { $expect = false; if ( '`' === $c ) { $end = strpos( $create, '`', $i + 1 ); if ( false === $end ) { self::reject( $original ); } $cols[] = substr( $create, $i + 1, $end - $i - 1 ); $i = $end + 1; continue; } if ( preg_match( '/\G[A-Za-z0-9_$]+/', $create, $m, 0, $i ) ) { if ( ! in_array( strtoupper( $m[0] ), self::NON_COLUMNS, true ) ) { $cols[] = $m[0]; } $i += strlen( $m[0] ); continue; } } if ( "'" === $c || '"' === $c ) { $end = self::quoted_end( $create, $i + 1, $c ); if ( null === $end ) { self::reject( $original ); } $i = $end + 1; } elseif ( '`' === $c ) { $end = strpos( $create, '`', $i + 1 ); $i = false === $end ? $n : $end + 1; } elseif ( '(' === $c ) { $depth++; $i++; } elseif ( ')' === $c ) { $depth--; $i++; } elseif ( ',' === $c && 1 === $depth ) { $expect = true; $i++; } else { $i++; } } if ( ! $cols ) { self::reject( $original ); } return $cols; } private static function quoted_end( string $s, int $i, string $q ): ?int { $n = strlen( $s ); while ( $i < $n ) { $i += strcspn( $s, '\\' . $q, $i ); if ( $i >= $n ) { return null; } if ( '\\' === $s[ $i ] || ( $s[ $i + 1 ] ?? '' ) === $q ) { $i += 2; continue; } return $i; } return null; } private static function reject( string $sql ): never { $sql = ltrim( $sql ); $verb = preg_match( '/^([A-Za-z]{1,20})/', $sql, $m ) ? strtoupper( $m[1] ) : '?'; $table = preg_match( '/^[A-Za-z]+(?:\s+(?:IGNORE|LOW_PRIORITY|DELAYED|HIGH_PRIORITY|IF\s+(?:NOT\s+)?EXISTS|TEMPORARY|TABLE|INTO|FROM|VIEW|DATABASE|SCHEMA|PROCEDURE|FUNCTION|TRIGGER))*\s+`?([A-Za-z0-9_$]{1,64})`?/i', $sql, $t ) ? ' ' . $t[1] : ''; throw new Mench_Migrate_Db_Exception( 'sql_statement_rejected: ' . $verb . $table, self::STEP ); } }