WHERE 1=1 before re-inserting (clean slate per table). * Caller is responsible for FSM rewind / cache flush around this call. * * @package WP_Data_Optimizer */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Snapshot reader: integrity check + apply. */ class TMDO_Snapshot_Reader { /** * Catalog row from wp_wpdo_snapshots. * * @var array */ private array $row; /** * Constructor. * * @param array $row Catalog row (must contain snapshot_id, storage, file_path, file_sha256, inline_blob, size_bytes). * @throws InvalidArgumentException When row is missing required keys. */ public function __construct( array $row ) { foreach ( array( 'snapshot_id', 'storage', 'size_bytes' ) as $required ) { if ( ! array_key_exists( $required, $row ) ) { throw new InvalidArgumentException( "snapshot row missing key: {$required}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output } } $this->row = $row; } /** * Verify sha256 + readability. * * @return array {ok:bool, sha256_ok:bool, size_match:bool, error?:string} */ public function verify(): array { $storage = (string) $this->row['storage']; $expected_size = (int) $this->row['size_bytes']; $expected_sha = (string) ( $this->row['file_sha256'] ?? '' ); if ( 'inline' === $storage ) { $blob = (string) ( $this->row['inline_blob'] ?? '' ); $actual_size = strlen( $blob ); // inline blobs aren't sha-checked by default — just check size. return array( 'ok' => $actual_size === $expected_size, 'sha256_ok' => true, 'size_match' => $actual_size === $expected_size, 'storage' => 'inline', ); } $path = (string) ( $this->row['file_path'] ?? '' ); if ( '' === $path || ! file_exists( $path ) ) { return array( 'ok' => false, 'error' => 'file_missing', 'storage' => 'file', ); } if ( ! is_readable( $path ) ) { return array( 'ok' => false, 'error' => 'file_unreadable', 'storage' => 'file', ); } $actual_size = (int) filesize( $path ); $actual_sha = hash_file( 'sha256', $path ); return array( 'ok' => ( $actual_size === $expected_size ) && ( $actual_sha === $expected_sha ), 'sha256_ok' => ( $actual_sha === $expected_sha ), 'size_match' => ( $actual_size === $expected_size ), 'storage' => 'file', ); } /** * Parse SQL stream and return a preview of tables + row counts (no apply). * * @return array {tables:array, total_rows:int, statements:int, sql_bytes:int} * @throws RuntimeException When payload cannot be loaded. */ public function preview(): array { $sql = $this->load_sql(); return $this->parse_summary( $sql ); } /** * Load + apply (DELETE + INSERT per table). * * For file-storage snapshots, sha256 is verified before execution. * Only tables whose names pass is_safe_name() (prefix-prefixed, alphanum+_) * are touched — arbitrary table names in the SQL file cannot be applied. * * @return array {tables:array, rows_restored:int, statements_run:int} * @throws RuntimeException When payload cannot be loaded or integrity check fails. */ public function apply(): array { global $wpdb; // Verify sha256 / size for file-storage snapshots before executing any SQL. if ( 'inline' !== (string) ( $this->row['storage'] ?? 'file' ) ) { $check = $this->verify(); if ( ! $check['ok'] ) { $reason = $check['error'] ?? ( $check['sha256_ok'] ? 'size_mismatch' : 'sha256_mismatch' ); throw new RuntimeException( 'snapshot integrity check failed: ' . $reason ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output } } $sql = $this->load_sql(); $summary = $this->parse_summary( $sql ); // Wipe target tables before inserting (clean slate). foreach ( array_keys( $summary['tables'] ) as $table ) { if ( $this->is_safe_name( $table ) ) { $wpdb->query( "DELETE FROM `{$table}`" ); // phpcs:ignore WordPress.DB } } $statements_run = 0; $rows_restored = 0; // Naive split on `;\n` — INSERT statements never contain unescaped semicolon-newline. $lines = preg_split( '/;\s*\n/', $sql ); if ( ! is_array( $lines ) ) { throw new RuntimeException( 'sql parse failed' ); } foreach ( $lines as $stmt ) { $stmt = trim( $stmt ); if ( '' === $stmt || str_starts_with( $stmt, '--' ) ) { continue; } if ( ! preg_match( '/^(INSERT|SET)\b/i', $stmt ) ) { continue; } $result = $wpdb->query( $stmt ); // phpcs:ignore WordPress.DB if ( false === $result ) { throw new RuntimeException( 'restore stmt failed: ' . substr( $stmt, 0, 80 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output } if ( stripos( $stmt, 'INSERT' ) === 0 ) { $rows_restored += (int) $result; } ++$statements_run; } return array( 'tables' => array_keys( $summary['tables'] ), 'rows_restored' => $rows_restored, 'statements_run' => $statements_run, ); } // ─── private ────────────────────────────────────────────────────────── /** * Load + ungzip the snapshot SQL payload. * * @return string Raw SQL. * @throws RuntimeException When payload is missing or unreadable. */ private function load_sql(): string { $storage = (string) $this->row['storage']; if ( 'inline' === $storage ) { $blob = (string) ( $this->row['inline_blob'] ?? '' ); if ( '' === $blob ) { throw new RuntimeException( 'inline_blob is empty' ); } return $this->maybe_gunzip( $blob ); } $path = (string) ( $this->row['file_path'] ?? '' ); if ( '' === $path || ! file_exists( $path ) ) { throw new RuntimeException( 'snapshot file missing: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output } if ( str_ends_with( $path, '.gz' ) ) { $content = ''; $handle = gzopen( $path, 'rb' ); if ( false === $handle ) { throw new RuntimeException( 'gzopen failed: ' . $path ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output } while ( ! gzeof( $handle ) ) { $content .= gzread( $handle, 65536 ); } gzclose( $handle ); return $content; } return (string) file_get_contents( $path ); } /** * Decompress if first 2 bytes are gzip magic. * * @param string $blob Possibly-gzipped data. * @return string Decompressed. */ private function maybe_gunzip( string $blob ): string { if ( strlen( $blob ) >= 2 && "\x1f\x8b" === substr( $blob, 0, 2 ) ) { $out = @gzdecode( $blob ); // phpcs:ignore WordPress.PHP.NoSilencedErrors return false === $out ? $blob : $out; } return $blob; } /** * Walk SQL and count INSERT rows per table. * * @param string $sql Raw SQL. * @return array {tables:array, total_rows:int, statements:int, sql_bytes:int} */ private function parse_summary( string $sql ): array { $tables = array(); $total = 0; $statements = 0; preg_match_all( '/INSERT INTO `([^`]+)` \([^)]*\) VALUES (.+?);\s*\n/s', $sql, $matches, PREG_SET_ORDER ); foreach ( $matches as $m ) { $table = $m[1]; $body = $m[2]; // Count `(` at the start of value tuples — substring_count of `,(` + 1. $rows = substr_count( $body, "),\n (" ) + 1; $tables[ $table ] = ( $tables[ $table ] ?? 0 ) + $rows; $total += $rows; ++$statements; } return array( 'tables' => $tables, 'total_rows' => $total, 'statements' => $statements, 'sql_bytes' => strlen( $sql ), ); } /** * Match safe table names: alphanumeric + underscore, must start with $wpdb->prefix. * * Mirrors TMDO_Snapshot_Writer::is_safe_name() so writer and reader apply * the same gate — an attacker-crafted SQL file cannot target tables outside * this site's WordPress prefix. * * @param string $name Table name. * @return bool */ private function is_safe_name( string $name ): bool { global $wpdb; return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name ) && strpos( $name, $wpdb->prefix ) === 0; } }