5 ) { return self::error(); } $origin = Derived::native_context( $caller ); $caller_handle = $caller->__get( 'dbh' ); if ( ! $caller_handle instanceof \mysqli ) { return self::error(); } try { $caller_thread = $caller_handle->thread_id; } catch ( \Throwable $error ) { // @phpstan-ignore catch.neverThrown (Native mysqli property access throws Error for an uninitialized or closed handle.) return self::error(); } $current = static function () use ( $caller, $origin, $caller_handle, $caller_thread ): bool { try { return ( $GLOBALS['wpdb'] ?? null ) === $caller && Derived::native_context( $caller ) === $origin && $caller_handle === $caller->__get( 'dbh' ) && $caller_thread === $caller_handle->thread_id; } catch ( \Throwable $error ) { return false; } }; $db = null; $handle = null; $observe = null; /** * WordPress query callbacks can change this captured flag. * * @var bool $changed */ $changed = false; $closed = false; $output = self::error(); try { if ( true !== $authorize() || ! $current() ) { return self::error(); } $network = Scope::network(); $site = get_current_blog_id(); if ( ! $current() ) { return self::error(); } if ( ! class_exists( WorkerConnection::class, false ) ) { require_once __DIR__ . '/class-workerconnection.php'; } $db = new WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); $handle = $db->__get( 'dbh' ); if ( ! $handle instanceof \mysqli || $handle === $caller_handle ) { $handle = null; return self::error(); } if ( ! $db->ready || ! $current() ) { return self::error(); } $db->set_prefix( $origin['base_prefix'] ); $db->set_blog_id( $site, $network ); $db->suppress_errors( true ); $worker = Derived::native_context( $db ); $bookkeeping = array( 'blogid' => true, 'siteid' => true, ); if ( ! $current() || array_diff_key( $worker, $bookkeeping ) !== array_diff_key( $origin, $bookkeeping ) ) { return self::error(); } $thread = $handle->thread_id; $stable = static fn(): bool => $current() && Derived::native_context( $db ) === $worker && $handle === $db->__get( 'dbh' ) && $thread === $handle->thread_id; $setup = null; $observe = static function ( string $sql ) use ( $db, $stable, &$setup, &$changed ): string { /** * Captured reference is read at invocation after setup/callback changes. * * @var string|null $setup */ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 20 live frames to bind original SQL, the owned preview connection and its fixed setup caller; no trace is emitted or persisted. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 20 ); foreach ( $trace as $index => $frame ) { if ( 'query' !== $frame['function'] || ! ( ( $frame['object'] ?? null ) instanceof \wpdb ) ) { continue; } $original = $frame['args'][0] ?? null; $next = $trace[ $index + 1 ] ?? array(); if ( $frame['object'] === $db ) { try { $bound = $stable(); } catch ( \Throwable $error ) { $bound = false; } if ( ! $bound ) { $changed = true; return ''; } $own_setup = null !== $setup && $setup === $sql && self::class === ( $next['class'] ?? '' ) && 'run' === ( $next['function'] ); if ( ! is_string( $original ) || $db->remove_placeholder_escape( $original ) !== $sql || ( ! $own_setup && ! preg_match( '/\ASELECT\s/i', $sql ) ) ) { $changed = true; return ''; } } elseif ( ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', trim( $sql ) ) ) { $permission_setup = $frame['object'] instanceof WorkerConnection && Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === ( $next['function'] ) && in_array( $sql, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ); if ( ! $permission_setup ) { $changed = true; return ''; } } break; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); // Locking SELECTs still read current committed rows in both modes. Immediate // exports also protect an absent unique privacy-generation option against // insertion until the final authorization; ordinary previews retain READ COMMITTED. $isolation = $lock_missing_keys ? 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ' : 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED'; foreach ( array( 'SET SESSION autocommit=1', $isolation, 'SET SESSION innodb_lock_wait_timeout=2' ) as $statement ) { $setup = $statement; if ( 0 !== $db->query( $statement ) || '' !== $db->last_error || $statement !== $db->last_query || ! $stable() || $changed ) { return self::error(); } } $setup = null; $fields = array( 'autocommit', 'isolation_level', 'lock_wait', 'database' ); $settings = self::rows( $db, 'SELECT @@SESSION.autocommit,@@SESSION.transaction_isolation,@@SESSION.innodb_lock_wait_timeout,DATABASE()', $fields, 1 ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( is_wp_error( $settings ) && '' !== $db->last_error && ! $changed && $stable() ) { // Older MariaDB exposes tx_isolation; MySQL8 exposes transaction_isolation. $settings = self::rows( $db, 'SELECT @@SESSION.autocommit,@@SESSION.tx_isolation,@@SESSION.innodb_lock_wait_timeout,DATABASE()', $fields, 1 ); } /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( is_wp_error( $settings ) || array( array( 'autocommit' => '1', 'isolation_level' => $lock_missing_keys ? 'REPEATABLE-READ' : 'READ-COMMITTED', 'lock_wait' => '2', 'database' => DB_NAME, ), ) !== $settings || ! $stable() || $changed ) { return self::error(); } foreach ( array( 'START TRANSACTION', 'SAVEPOINT logged_ai_command_preview', 'RELEASE SAVEPOINT logged_ai_command_preview' ) as $statement ) { $setup = $statement; /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( 0 !== $db->query( $statement ) || '' !== $db->last_error || $statement !== $db->last_query || ! $stable() || $changed ) { return self::error(); } } $setup = null; $result = $read( $db ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( ! is_array( $result ) || ! $stable() || $changed ) { return self::error(); } // The selection has already acquired native table metadata locks. Engine // checks here cannot race an earlier ALTER before the protected read. foreach ( $tables as $table ) { if ( ! is_string( $table ) || ! preg_match( '/\A[a-zA-Z0-9_]{1,64}\z/D', $table ) || ! str_starts_with( $table, $origin['base_prefix'] ) ) { return self::error(); } $engine = self::rows( $db, $db->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s LIMIT 1', $table ), array( 'engine' ), 1 ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( is_wp_error( $engine ) || array( array( 'engine' => 'InnoDB' ) ) !== $engine || ! $stable() || $changed ) { return self::error(); } } $database = self::rows( $db, 'SELECT DATABASE()', array( 'database' ), 1 ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( is_wp_error( $database ) || array( array( 'database' => DB_NAME ) ) !== $database || ! $stable() || $changed ) { return self::error(); } // The final permission call is last: retained rows remain locked until // private native close, and no later callback-bearing read discloses data. /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( true !== $authorize() || ! $stable() || $changed ) { return self::error(); } $output = $result; } catch ( \Throwable $error ) { $output = self::error(); } finally { if ( null !== $observe ) { remove_filter( 'query', $observe, PHP_INT_MAX ); } if ( $db instanceof WorkerConnection ) { remove_filter( 'query', array( $db, 'remove_placeholder_escape' ), 0 ); } if ( $db instanceof \wpdb && $handle instanceof \mysqli ) { // A callback may replace wpdb's handle. Close only the connection // created here, never an adopted caller or another borrowed handle. try { $closed = true === ( $handle === $db->__get( 'dbh' ) ? $db->close() : $handle->close() ); } catch ( \Throwable $error ) { $closed = false; } } } return $closed && $current() ? $output : self::error(); } /** * Read finite cached columns only after the exact native SELECT actually succeeded. * * @phpstan-impure * @param \wpdb $db Private preview connection. * @param string $sql Prepared fixed SELECT with a finite limit. * @param array $fields Exact positional column names. * @param int $limit Maximum accepted result rows, at most500. * @return array|\WP_Error Selected scalar rows or a storage refusal. */ public static function rows( \wpdb $db, string $sql, array $fields, int $limit ): array|\WP_Error { if ( count( $fields ) < 1 || count( $fields ) > 16 || $limit < 1 || $limit > 500 ) { return self::error(); } $selected = $db->query( $sql ); if ( ! is_int( $selected ) || $selected < 0 || $selected > $limit || '' !== $db->last_error || $db->remove_placeholder_escape( $sql ) !== $db->last_query ) { return self::error(); } $rows = array(); for ( $i = 0; $i < $selected; ++$i ) { $row = array(); foreach ( $fields as $j => $name ) { $row[ $name ] = $db->get_var( null, $j, $i ); } $rows[] = $row; } return $rows; } /** * Keep native diagnostics private and return the same bounded refusal. * * @return \WP_Error Generic retryable preview refusal. */ private static function error(): \WP_Error { return new \WP_Error( 'preview_unavailable', 'The current preview could not be completed without changing the caller transaction.', array( 'status' => 503 ) ); } }