[
]' ): 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 );
}
}