* } $options * @return array{statements: int, bytes_processed: int, duration_ms: int, mode?: string, promoted_tables?: string[]} * @throws RuntimeException On file/open/query failures. */ public function restore_from_file( string $dump_path, array $options = [] ): array { if ( ! is_file( $dump_path ) || ! is_readable( $dump_path ) ) { throw new RuntimeException( "Dump file not found: {$dump_path}" ); } $skip_errors = (bool) ( $options['skip_errors'] ?? false ); $dry_run = (bool) ( $options['dry_run'] ?? false ); $use_transaction = (bool) ( $options['use_transaction'] ?? true ); $statement_filter = $options['statement_filter'] ?? null; $start_time = microtime( true ); // Staged mode rebuilds the dump in shiftora_bak_rst_* tables, verifies them, // then promotes the batch in one atomic RENAME. Live tables are untouched // until every table verifies, so an interrupted restore never leaves a // half-written one. No transaction wrapper: the promotion is the atomic unit. $staged = (bool) ( $options['staged'] ?? false ); $ledger = $options['ledger'] ?? null; $backup_id = $options['backup_id'] ?? null; $backup_id = is_int( $backup_id ) ? $backup_id : ( null === $backup_id ? null : (int) $backup_id ); if ( $ledger && null === $backup_id ) { throw new RuntimeException( 'A staged restore with a ledger requires a backup_id.' ); } $is_gz = strtolower( (string) pathinfo( $dump_path, PATHINFO_EXTENSION ) ) === 'gz'; $handle = $is_gz ? gzopen( $dump_path, 'rb' ) : fopen( $dump_path, 'rb' ); if ( false === $handle ) { throw new RuntimeException( "Failed to open dump file: {$dump_path}" ); } $staged_state = null; $plan_map = []; $verified_flags = []; $ledger_status = null; $resume_after_rename = false; // The ledger session opens or resumes once the header plan is parsed. A // surviving session marks a retry: verified staging tables stay and promotion resumes. if ( $staged ) { $plan_map = $this->parse_tables_header( $dump_path, $is_gz, $handle ); // The header scan consumed the top of the stream; rewind for the digest pass. $is_gz ? gzrewind( $handle ) : rewind( $handle ); if ( $ledger ) { // A ledger row only describes this dump if its key matches; resuming from an // unattributed row would skip tables this dump has yet to load. $dump_key = (string) ( $options['dump_key'] ?? '' ); $same_dump = '' !== $dump_key && '' !== $ledger->dump_key( $backup_id ) && hash_equals( $dump_key, $ledger->dump_key( $backup_id ) ); // The fast-finish below decides on 'ready', so read it through the same check. $ledger_status = $same_dump ? $ledger->status( $backup_id ) : null; if ( null !== $ledger_status ) { $verified_flags = array_fill_keys( $ledger->verified( $backup_id ), true ); $unknown = array_diff( array_keys( $verified_flags ), array_keys( $plan_map ) ); if ( [] !== $unknown ) { throw new RuntimeException( 'Restore ledger names tables this dump does not plan: ' . implode( ', ', $unknown ) ); } } else { $ledger->begin( $backup_id, array_keys( $plan_map ), $dump_key ); } } else { foreach ( (array) ( $options['verified_tables'] ?? [] ) as $bare ) { if ( isset( $plan_map[ $bare ] ) ) { $verified_flags[ $bare ] = true; } } } $staged_state = [ 'map' => $plan_map, 'expected' => [], 'verified' => $verified_flags, 'constraints' => [], ]; } // A present digest must hold before any statement runs; same-size corruption passes a size check. $this->verify_dump_digest( $dump_path, $is_gz, $handle ); $statement = ''; $statements = 0; $total_bytes = 0; $in_string = false; $string_char = ''; $escape_next = false; $in_line_comment = false; $in_block_comment = false; $is_atomic = ! $dry_run && $use_transaction && ! $staged; // The dump can carry zero-dates the source accepted in non-strict mode. // Relaxed for the whole loop, restored in the finally block. $sql_mode_before = SessionSqlMode::relax( $this->wpdb ); if ( $is_atomic ) { $this->wpdb->query( 'SET FOREIGN_KEY_CHECKS=0' ); $this->wpdb->query( 'START TRANSACTION' ); } try { // A `ready` ledger is ambiguous: the promotion RENAME and its receipt are // separate writes, so a crash between them leaves promoted data with a ready // row. Check the disk first so missing staging tables are not read as a torn restore. if ( $staged && null !== $ledger_status && 'ready' === $ledger_status ) { $resume_after_rename = ( 'resumed' === $this->resolve_ready_disposition( $plan_map ) ); } while ( true ) { $line = $is_gz ? gzgets( $handle, self::READ_CHUNK_BYTES ) : fgets( $handle, self::READ_CHUNK_BYTES ); if ( false === $line ) { if ( $is_gz ? gzeof( $handle ) : feof( $handle ) ) { break; } throw new RuntimeException( "Failed while reading dump file: {$dump_path}" ); } $total_bytes += strlen( $line ); $line_len = strlen( $line ); for ( $i = 0; $i < $line_len; $i++ ) { $char = $line[ $i ]; $next = $i + 1 < $line_len ? $line[ $i + 1 ] : ''; if ( ! $in_line_comment && ! $in_block_comment && ! $in_string ) { $pos = self::next_special( $line, $i ); if ( $pos !== false && $pos !== $i ) { $statement .= substr( $line, $i, $pos - $i ); $i = $pos; $char = $line[ $i ]; $next = $i + 1 < $line_len ? $line[ $i + 1 ] : ''; } } $statement .= $char; if ( $in_line_comment ) { if ( "\n" === $char ) { $in_line_comment = false; } continue; } if ( $in_block_comment ) { if ( '*' === $char && '/' === $next ) { $statement .= '/'; $in_block_comment = false; $i++; } continue; } if ( $in_string ) { if ( $escape_next ) { $escape_next = false; continue; } if ( '\\' === $char ) { $escape_next = true; continue; } if ( $char === $string_char ) { $in_string = false; } continue; } if ( '-' === $char && '-' === $next ) { $third = $i + 2 < $line_len ? $line[ $i + 2 ] : ''; if ( '' === $third || ctype_space( $third ) ) { $statement .= '-'; $in_line_comment = true; $i++; continue; } } if ( '#' === $char ) { $in_line_comment = true; continue; } if ( '/' === $char && '*' === $next ) { $statement .= '*'; $in_block_comment = true; $i++; continue; } if ( '"' === $char || "'" === $char || '`' === $char ) { $in_string = true; $string_char = $char; continue; } if ( ';' !== $char ) { continue; } $executed = $this->execute_statement( $statement, $skip_errors, $dry_run, $statement_filter, $staged_state ); if ( $executed ) { $statements++; } $statement = ''; } } $tail_executed = $this->execute_statement( $statement, $skip_errors, $dry_run, $statement_filter, $staged_state ); if ( $tail_executed ) { $statements++; } $promoted_tables = []; if ( $staged ) { $staged_map = $staged_state['map']; $dump_constraints = $staged_state['constraints']; if ( $resume_after_rename ) { // Promotion landed and only the receipt is missing: restore the dump's // constraint names and drop leftover snapshots. $this->finish_promotion( $staged_map, $dump_constraints ); if ( $ledger ) { $ledger->commit( $backup_id ); } $promoted_tables = array_keys( $staged_map ); } else { $this->verify_staged_row_counts( $staged_state['expected'], $staged_map, $verified_flags, $ledger, $backup_id ); if ( $ledger ) { $ledger->mark_ready( $backup_id ); } $this->promote_staged_batch( $staged_map, $dump_constraints ); if ( $ledger ) { $ledger->commit( $backup_id ); } $promoted_tables = array_keys( $staged_map ); } } if ( $is_atomic ) { $this->wpdb->query( 'COMMIT' ); $this->wpdb->query( 'SET FOREIGN_KEY_CHECKS=1' ); } $result = [ 'statements' => $statements, 'bytes_processed' => $total_bytes, 'duration_ms' => (int) ( ( microtime( true ) - $start_time ) * 1000 ), ]; if ( $staged ) { $result['mode'] = $resume_after_rename ? 'resumed' : 'staged'; $result['promoted_tables'] = $promoted_tables; } return $result; } catch ( \Throwable $e ) { if ( $is_atomic ) { $this->wpdb->query( 'ROLLBACK' ); $this->wpdb->query( 'SET FOREIGN_KEY_CHECKS=1' ); } throw new RuntimeException( 'Streaming restore failed: ' . $e->getMessage(), 0, $e ); } finally { SessionSqlMode::restore( $this->wpdb, $sql_mode_before ); if ( $is_gz ) { gzclose( $handle ); } else { fclose( $handle ); } } } /** * Reads the `-- Tables:` header and maps every bare table to its staging and * old-snapshot names. Staged mode refuses dumps without it: with no plan there * is no safe way to tell which statements belong to the restore. * * @param resource $handle Opened handle at the start of the stream. * @return array Bare => names. * @throws RuntimeException When the dump carries no usable header. */ private function parse_tables_header( string $dump_path, bool $is_gz, $handle ): array { $tables = null; for ( $i = 0; $i < 8; $i++ ) { $line = $is_gz ? gzgets( $handle, self::READ_CHUNK_BYTES ) : fgets( $handle, self::READ_CHUNK_BYTES ); if ( false === $line ) { break; } if ( preg_match( '/^--\s*Tables:\s*(.+)$/', $line, $m ) ) { $tables = array_values( array_filter( array_map( 'trim', explode( ',', $m[1] ) ) ) ); break; } } if ( null === $tables || [] === $tables ) { throw new RuntimeException( "Staged restore aborted: dump has no `-- Tables:` header ({$dump_path})." ); } $map = []; foreach ( $tables as $bare ) { $map[ $bare ] = [ 'staging' => self::STAGING_PREFIX . $bare, 'old' => self::OLD_PREFIX . $bare, ]; } return $map; } /** * Rewrites a dump statement onto its staging table, or passes a session flag * through. The dumper emits only DROP, CREATE and INSERT against declared * tables plus SET FOREIGN_KEY_CHECKS, so anything else is refused. * * @param array{map: array, expected: array, constraints: array>} $staged_state * @return string The staging-targeted statement. * @throws RuntimeException On an unattributable statement. */ private function staged_rewrite( string $sql, array &$staged_state ): string { if ( preg_match( '/^SET\s+FOREIGN_KEY_CHECKS\s*=\s*[01];?$/i', $sql ) ) { return $sql; } if ( preg_match( '/^DROP\s+TABLE\s+IF\s+EXISTS\s+`([^`]+)`;?$/i', $sql, $m ) ) { $bare = $m[1]; $map = $staged_state['map']; if ( ! isset( $map[ $bare ] ) ) { throw new RuntimeException( "Staged restore cannot attribute a DROP for `{$bare}` to the dump's tables; refusing to guess." ); } if ( isset( $staged_state['verified'][ $bare ] ) ) { return ''; } return "DROP TABLE IF EXISTS `{$map[ $bare ]['staging']}`;"; } if ( preg_match( '/^CREATE\s+TABLE\s+`([^`]+)`\s*\(/i', $sql, $m ) ) { $bare = $m[1]; $map = $staged_state['map']; if ( ! isset( $map[ $bare ] ) ) { throw new RuntimeException( "Staged restore cannot attribute this CREATE TABLE to the dump's tables; refusing to guess: " . substr( $sql, 0, 120 ) ); } // Captured before rewriting: the dump's own text is the only copy of these // clauses that never goes through a RENAME, so promotion restores them verbatim. $staged_state['constraints'][ $bare ] = SchemaFingerprint::constraint_clauses( $sql ); if ( isset( $staged_state['verified'][ $bare ] ) ) { return ''; } // Scope the constraint names: they are unique per schema, so the staging // copy cannot reuse the live table's. $rewritten = (string) preg_replace( '/^CREATE\s+TABLE\s+`[^`]+`/', "CREATE TABLE `{$map[ $bare ]['staging']}`", $sql, 1 ); return SchemaFingerprint::scope_constraint_names( $rewritten ); } if ( preg_match( '/^INSERT\s+INTO\s+`([^`]+)`\s*\(/i', $sql, $m ) ) { $bare = $m[1]; $map = $staged_state['map']; if ( ! isset( $map[ $bare ] ) ) { throw new RuntimeException( "Staged restore cannot attribute an INSERT for `{$bare}` to the dump's tables; refusing to guess." ); } if ( isset( $staged_state['verified'][ $bare ] ) ) { // Staging already holds these rows from the earlier attempt. return ''; } $rewritten = (string) preg_replace( '/^INSERT\s+INTO\s+`[^`]+`/', "INSERT INTO `{$map[ $bare ]['staging']}`", $sql, 1 ); $staged_state['expected'][ $bare ] = ( $staged_state['expected'][ $bare ] ?? 0 ) + self::insert_row_count( $rewritten ); return $rewritten; } throw new RuntimeException( 'Staged restore cannot attribute this statement to a dump table; refusing to guess: ' . substr( $sql, 0, 120 ) ); } /** * Counts the row tuples in one multi-row INSERT, honoring quoted strings and * backslash escapes. Each closing top-level parenthesis is one row. */ private static function insert_row_count( string $sql ): int { $pos = (int) stripos( $sql, 'VALUES' ); if ( 0 === $pos ) { return 0; } $depth = 0; $rows = 0; $len = strlen( $sql ); for ( $i = $pos + strlen( 'VALUES' ); $i < $len; $i++ ) { $char = $sql[ $i ]; if ( "'" === $char ) { $i++; while ( $i < $len ) { if ( '\\' === $sql[ $i ] ) { $i++; } elseif ( "'" === $sql[ $i ] ) { break; } $i++; } continue; } if ( '(' === $char ) { $depth++; continue; } if ( ')' === $char && $depth > 0 ) { $depth--; if ( 0 === $depth ) { $rows++; } } } return $rows; } /** * Compares each staging table's row count with what the dump emitted; a short * table would promote silently truncated data. Tables confirmed on an earlier * attempt are skipped, and each newly confirmed one is recorded in the ledger. * * @param array $expected Bare => rows the dump holds. * @param array $plan_map * @param array $verified Bare tables already confirmed. * @throws RuntimeException On any mismatch, before promotion. */ private function verify_staged_row_counts( array $expected, array $plan_map, array $verified, ?RestoreTable $ledger, ?int $backup_id ): void { foreach ( $plan_map as $bare => $names ) { if ( isset( $verified[ $bare ] ) ) { continue; } $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the transfer reads and writes the site's own tables, a cached copy would be stale $count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $names['staging'] ) ); $got = (int) $count; $want = (int) ( $expected[ $bare ] ?? 0 ); if ( $got !== $want ) { throw new RuntimeException( "Staged restore failed: row count mismatch for {$bare} (staging held {$got}, dump holds {$want}); refusing to promote." ); } if ( $ledger ) { $ledger->mark_verified( $backup_id, $bare ); } } } /** * Promotes every verified staging table in one atomic RENAME TABLE, moving * existing real tables aside to snapshots in the same statement, then drops * them since verification already proved the restored data. * * @param array $plan_map * @param array> $constraints The dump's own clauses, by table. * @throws RuntimeException When the batch rename fails. */ private function promote_staged_batch( array $plan_map, array $constraints ): void { $pairs = []; foreach ( $plan_map as $bare => $names ) { if ( $this->table_exists( $bare ) ) { $pairs[] = [ $bare, $names['old'] ]; } $pairs[] = [ $names['staging'], $bare ]; } // Renaming a parent table that children reference fails under FK checks. $this->with_fk_checks_off( function () use ( $pairs ): void { $renamed = TableDdl::rename( $this->wpdb, $pairs ); if ( false === $renamed ) { throw new RuntimeException( 'Staged restore failed to promote tables: ' . $this->wpdb->last_error ); } } ); $this->finish_promotion( $plan_map, $constraints ); } /** * Detaches the staging-scoped constraints, discards the snapshots and puts the * dump's constraints back. * * The RENAME retargets staging's reserved-name clauses at the snapshots, so * neither their names nor targets are usable. The dump's own text never went * through a rename, so its names are free and its REFERENCES still correct. * * @param array $plan_map * @param array> $constraints * @throws RuntimeException When a constraint cannot be detached or put back. */ private function finish_promotion( array $plan_map, array $constraints ): void { $this->detach_staged_constraints( $plan_map, $constraints ); // Clear every snapshot's constraints before dropping any; leftover foreign // keys would hold the names about to be reused. foreach ( $plan_map as $bare => $names ) { $this->drop_named_constraints( $names['old'], $constraints[ $bare ] ?? [] ); } foreach ( $plan_map as $names ) { TableDdl::drop( $this->wpdb, $names['old'] ); } $this->attach_constraints( $constraints ); } /** * Drops the constraints staging created under its reserved suffix. * * @param array $plan_map * @param array> $constraints The dump's own clauses, by table. * @throws RuntimeException When a staged constraint has nothing to replace it. */ private function detach_staged_constraints( array $plan_map, array $constraints ): void { foreach ( array_keys( $plan_map ) as $bare ) { foreach ( SchemaFingerprint::constraint_clauses( $this->create_table_ddl( $bare ) ) as $clause ) { if ( ! SchemaFingerprint::is_staged_constraint( $clause ) ) { continue; } // With nothing captured to put back, dropping would lose the relationship. if ( [] === ( $constraints[ $bare ] ?? [] ) ) { throw new RuntimeException( "Restore staged a constraint for `{$bare}` but holds none of its own to replace it; refusing to drop it." ); } $name = SchemaFingerprint::constraint_name( $clause ); if ( ! TableDdl::drop_constraint( $this->wpdb, $bare, $name, $clause ) ) { throw new RuntimeException( "Restore could not detach `{$name}` from `{$bare}`: {$this->wpdb->last_error}" ); } } } } /** * Drops a table's own copy of the named constraints so the live tables can * reuse those names. Failures are ignored: the table is going away anyway. * * @param string[] $clauses Clauses whose names to look for. */ private function drop_named_constraints( string $table, array $clauses ): void { $wanted = []; foreach ( $clauses as $clause ) { $name = SchemaFingerprint::constraint_name( $clause ); if ( '' !== $name ) { $wanted[] = $name; } } if ( [] === $wanted || ! $this->table_exists( $table ) ) { return; } foreach ( SchemaFingerprint::constraint_clauses( $this->create_table_ddl( $table ) ) as $own ) { $name = SchemaFingerprint::constraint_name( $own ); if ( '' === $name || ! in_array( $name, $wanted, true ) ) { continue; } TableDdl::drop_constraint( $this->wpdb, $table, $name, $own ); } } /** * Puts the dump's constraint clauses on the tables holding its rows. A clause * MySQL refuses is reported rather than leaving a table silently missing a foreign key. * * @param array> $constraints * @throws RuntimeException When a clause will not go back on. */ private function attach_constraints( array $constraints ): void { foreach ( $constraints as $bare => $clauses ) { if ( [] === $clauses ) { continue; } $live = []; foreach ( SchemaFingerprint::constraint_clauses( $this->create_table_ddl( $bare ) ) as $own ) { $name = SchemaFingerprint::constraint_name( $own ); if ( '' !== $name ) { $live[ $name ] = true; } } foreach ( $clauses as $clause ) { $name = SchemaFingerprint::constraint_name( $clause ); // A previous attempt whose receipt was never written already added this // clause; adding it again would fail as a duplicate. if ( isset( $live[ $name ] ) ) { continue; } if ( ! TableDdl::add_constraint( $this->wpdb, $bare, $clause ) ) { throw new RuntimeException( sprintf( 'Restore could not put constraint `%s` back on `%s`: %s', $name, $bare, (string) $this->wpdb->last_error ) ); } } } } /** A table's own CREATE statement, or '' when it is not there. Reads column 2; get_var() would return the name. */ private function create_table_ddl( string $table ): string { return TableDdl::create_statement( $this->wpdb, $table ); } /** Runs $fn with foreign key checks off and always restores them, even on failure. */ private function with_fk_checks_off( callable $fn ): void { $this->wpdb->query( 'SET FOREIGN_KEY_CHECKS=0' ); try { $fn(); } finally { $this->wpdb->query( 'SET FOREIGN_KEY_CHECKS=1' ); } } /** * Classifies the on-disk state of a restore whose ledger says `ready`. * * All staging present means the rename never ran. No staging beside a full set * of live tables means it ran and only the receipt is missing. Anything between * cannot come from an atomic RENAME, so it is refused. * * @param array $plan_map * @return string 'restore' (promote again) or 'resumed' (only the receipt remains). * @throws RuntimeException On an ambiguous on-disk state. */ private function resolve_ready_disposition( array $plan_map ): string { $staging_present = []; $staging_absent = []; $live_absent = []; foreach ( $plan_map as $bare => $names ) { if ( $this->table_exists( $names['staging'] ) ) { $staging_present[] = $names['staging']; } else { $staging_absent[] = $names['staging']; } if ( ! $this->table_exists( $bare ) ) { $live_absent[] = $bare; } } if ( [] === $staging_absent ) { return 'restore'; } if ( [] === $staging_present && [] === $live_absent ) { return 'resumed'; } throw new RuntimeException( 'Restore ledger is ready but the on-disk state is torn; refusing to guess. ' . 'Staging tables missing: ' . implode( ', ', $staging_absent ) . ( [] === $live_absent ? '' : '; live tables missing: ' . implode( ', ', $live_absent ) ) ); } /** Whether a table currently exists. */ private function table_exists( string $table ): bool { return TableDdl::exists( $this->wpdb, $table ); } /** * Hashes the dump and compares it to its trailing `-- SHA-256:` line. * * The tail is held back from the hash until EOF so the digest line can be * excluded. A file without a digest line is not verified. * * @param resource $handle Opened handle, left rewound for the caller. * @throws RuntimeException On a digest mismatch. */ private function verify_dump_digest( string $dump_path, bool $is_gz, $handle ): void { // Enough room for the digest line and a trailing CRLF. $hold = 1024; $ctx = hash_init( 'sha256' ); $tail = ''; while ( true ) { $line = $is_gz ? gzgets( $handle, self::READ_CHUNK_BYTES ) : fgets( $handle, self::READ_CHUNK_BYTES ); if ( false === $line ) { break; } $pending = $tail . $line; if ( strlen( $pending ) > $hold ) { hash_update( $ctx, substr( $pending, 0, -$hold ) ); $tail = substr( $pending, -$hold ); } else { $tail = $pending; } } // The digest line, when present, is the final line. if ( ! preg_match( '/-- SHA-256: ([0-9a-f]{64})\r?\n$/s', $tail, $m ) ) { $is_gz ? gzrewind( $handle ) : rewind( $handle ); return; } hash_update( $ctx, substr( $tail, 0, -strlen( $m[0] ) ) ); $expected = $m[1]; $computed = hash_final( $ctx, false ); $is_gz ? gzrewind( $handle ) : rewind( $handle ); if ( ! hash_equals( $expected, $computed ) ) { throw new RuntimeException( "Streaming restore failed: dump digest mismatch ({$dump_path}); refusing to apply a corrupt backup." ); } } /** * Executes one statement when it is non-empty and accepted by the filter. * * In staged mode it is first rewritten onto the staging table, and anything * unattributable fails closed instead of running against a live table. * * @param array{map: array, expected: array, constraints: array>}|null $staged_state */ private function execute_statement( string $statement, bool $skip_errors, bool $dry_run, mixed $statement_filter, ?array &$staged_state = null ): bool { $statement = trim( $statement ); $statement = self::strip_leading_comments( $statement ); if ( '' === $statement ) { return false; } if ( null !== $staged_state ) { $statement = $this->staged_rewrite( $statement, $staged_state ); if ( '' === $statement ) { // Verified on an earlier attempt; its staging table already has the data. return false; } } if ( is_callable( $statement_filter ) ) { $filtered = $statement_filter( $statement ); if ( ! is_string( $filtered ) ) { return false; } $statement = trim( $filtered ); if ( '' === $statement ) { return false; } } // A restore file is our own backup or the admin's upload, so it is not held to the peer // allow-list, but nothing in it has a reason to touch the database server's files. if ( PeerSql::reaches_server_files( $statement ) ) { throw new RuntimeException( 'The SQL file contains a statement that reads or writes files on the database server, so it was not run.' ); } if ( $dry_run ) { return true; } // A dump from another server carries its own DDL dialect. if ( 1 === preg_match( '/^CREATE\s+TABLE\b/i', $statement ) ) { $statement = ServerDdl::adapt( $this->wpdb, $statement ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the admin's own dump statement, already split and filtered; nothing for prepare() to bind. $result = $this->wpdb->query( $statement ); if ( false === $result && ! $skip_errors ) { throw new RuntimeException( "SQL error: {$this->wpdb->last_error}" ); } return true; } /** * Removes the leading comment run from a statement, keeping the SQL body. * * A dump folds a header comment into the statement after it, so discarding any * statement that starts with a comment would drop real SQL. Executable `/*!` * comments are preserved, and `--` only starts a comment before whitespace. */ private static function strip_leading_comments( string $sql ): string { while ( true ) { $sql = ltrim( $sql ); if ( '' === $sql ) { return ''; } if ( str_starts_with( $sql, '--' ) ) { $after = $sql[2] ?? ''; if ( ! ctype_space( $after ) ) { return $sql; } $nl = strpos( $sql, "\n" ); if ( false === $nl ) { return ''; } $sql = substr( $sql, $nl + 1 ); continue; } if ( '#' === $sql[0] ) { $nl = strpos( $sql, "\n" ); if ( false === $nl ) { return ''; } $sql = substr( $sql, $nl + 1 ); continue; } if ( str_starts_with( $sql, '/*' ) ) { if ( isset( $sql[2] ) && '!' === $sql[2] ) { return $sql; } $end = strpos( $sql, '*/' ); if ( false === $end ) { return ''; } $sql = substr( $sql, $end + 2 ); continue; } return $sql; } } /** * Finds the next state-altering character in a SQL line, so the parser can skip * bytes in bulk instead of concatenating per character. * * @return int|false Position of the next special character, or false. */ private static function next_special( string $line, int $start ): int|false { $len = strlen( $line ); if ( $start >= $len ) { return false; } $pos = $start; while ( $pos < $len ) { $c = $line[ $pos ]; if ( ';' === $c || "'" === $c || '"' === $c || '`' === $c || '-' === $c || '#' === $c || '/' === $c || '\\' === $c ) { return $pos; } $pos++; } return false; } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.WP.AlternativeFunctions.file_system_operations_fopen }