Files
2meet-data-optimizer/includes/snapshots/class-tmdo-snapshot-reader.php
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 05:06:36 +08:00

267 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
*/
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;
}
}