76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
269 lines
8.7 KiB
PHP
269 lines
8.7 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Snapshot_Reader — preview, verify, and apply a stored snapshot
|
|
* (v2.2.0 M1).
|
|
*
|
|
* Loading order:
|
|
* 1. Read catalog row from wp_wpdo_snapshots (passed in by manager).
|
|
* 2. If `inline_blob`, decompress in-memory.
|
|
* 3. Else read file from disk + verify sha256 + ungzip.
|
|
* 4. Parse SQL into tables and INSERT statements.
|
|
* 5. preview() returns parse summary; apply() executes statements.
|
|
*
|
|
* Restore policy:
|
|
* - DELETE FROM <table> 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
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Snapshot reader: integrity check + apply.
|
|
*/
|
|
class TMDO_Snapshot_Reader {
|
|
|
|
/**
|
|
* Catalog row from wp_wpdo_snapshots.
|
|
*
|
|
* @var array<string,mixed>
|
|
*/
|
|
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<string,int>, 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<int,string>, 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<string,int>, 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;
|
|
}
|
|
}
|