db = $db; $this->scope = $scope; $this->origin = Derived::native_context( $db ); $this->uninstall_choice = 'uninstall_choice' === $kind; $this->option = $this->uninstall_choice ? 'logged_ai_delete_all_on_uninstall' : ( 'settings' === $kind ? 'logged_ai_settings' : 'logged_ai_collection' ); $this->option_network = $this->uninstall_choice && is_multisite() ? $scope->network_id : null; if ( $this->uninstall_choice && ! is_multisite() ) { $this->capabilities = array( 'logged_ai_manage_settings', 'logged_ai_purge_data' ); } $this->lock = ( 'settings' === $kind ? 'lai_settings_' : 'lai_collection_' ) . substr( hash( 'sha256', DB_NAME . ( 'settings' === $kind ? ':' . $scope->site_id . ':' . $db->base_prefix : ':' . $db->base_prefix . ':' . $scope->site_id ) ), 0, 32 ); if ( $this->uninstall_choice ) { $this->lock = 'lai_uninstall_' . substr( hash( 'sha256', DB_NAME . ':' . $db->base_prefix ), 0, 32 ); } } /** * Run and verify a fixed option mutation under the original connection lease. * * @param-immediately-invoked-callable $work * @param-immediately-invoked-callable $verify * * @param Scope $scope Current native authorization scope selected for this operation. * @param string $kind Fixed settings, collection or uninstall-choice mutation kind. * @param callable $work Immediately invoked operation receiving the bound option guard. * @param callable $verify Immediately invoked final check of the guard and returned work result. * * @return mixed Verified work result, or a scope, authority, lease, storage or stale-result error. */ public static function run( Scope $scope, string $kind, callable $work, callable $verify ): mixed { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! in_array( $kind, array( 'settings', 'collection', 'uninstall_choice' ), true ) ) { return new \WP_Error( 'scope', 'Unsupported settings mutation.' ); } $guard = new self( $scope, $kind, $wpdb ); $valid = $guard->read( static fn() => true ); if ( is_wp_error( $valid ) ) { return $valid; } $acquired_value = null; $acquired = $guard->write( static function () use ( $guard, &$acquired_value ) { $queried = $guard->db->query( $guard->db->prepare( 'SELECT GET_LOCK(%s,1)', $guard->lock ) ); $acquired_value = 1 === $queried ? $guard->db->get_var( null ) : null; return $acquired_value; } ); // The query may have acquired the lock before a native callback changed // context. Always release that original connection's exact key below. $locked = '1' === (string) $acquired_value; if ( is_wp_error( $acquired ) ) { if ( $locked ) { $guard->release(); } return $acquired; } if ( ! $locked ) { return new \WP_Error( 'busy', 'Another settings change is running. Retry.' ); } try { $result = $guard->current() ? $work( $guard ) : self::changed(); } catch ( \Throwable $error ) { $result = new \WP_Error( 'storage', 'Settings verification was interrupted. Inspect current settings before retrying.' ); } finally { $released = $guard->release(); } if ( ! $released ) { return new \WP_Error( 'storage', 'Settings may have changed, but lock release could not be verified. Inspect current settings before retrying.' ); } if ( ! $guard->current() ) { return self::changed(); } if ( is_wp_error( $result ) ) { return $result; } // RELEASE_LOCK is callback-bearing. No callback follows this final seal. return $guard->read( static fn() => true === $verify( $guard, $result ) ? $result : new \WP_Error( 'stale', 'Saved settings changed before confirmation. Reload their current state.' ) ); } /** * Compare the current native connection context with its captured origin. * * @phpstan-impure Pure captured native context; never repairs drift. * * @return bool Whether the bound native context remains unchanged. */ public function current(): bool { return Derived::native_context( $this->db ) === $this->origin; } /** * Create an authority/context error without implying earlier writes were rolled back. * * @return \WP_Error Structured error carrying HTTP status 403 and current-state review guidance. */ private static function changed(): \WP_Error { return new \WP_Error( 'forbidden', 'Settings authority, configuration or context changed. A saved change may remain; inspect current settings before retrying.', array( 'status' => 403 ) ); } /** * Release the original mutex key and detect callback changes to its statement. * * @return bool Whether exact lease release was verified without an observed statement rewrite. */ private function release(): bool { $expected = $this->db->remove_placeholder_escape( $this->db->prepare( 'SELECT RELEASE_LOCK(%s)', $this->lock ) ); $changed = false; $observe = function ( string $sql ) use ( $expected, &$changed ): string { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 12 live frames to restore only this mutation owner's original lock-release SQL after a rewrite; no trace is emitted or persisted. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 12 ); foreach ( $trace as $frame ) { if ( 'query' === $frame['function'] && ( $frame['object'] ?? null ) instanceof \wpdb ) { $original = $frame['args'][0] ?? null; if ( $frame['object'] === $this->db && is_string( $original ) && $this->db->remove_placeholder_escape( $original ) === $expected && $sql !== $expected ) { $changed = true; return $expected; // Release only our captured key, but deny successful confirmation. } break; } } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $queried = $this->db->query( $expected ); $released = 1 === $queried && '1' === (string) $this->db->get_var( null ); return $released && ! $changed; } catch ( \Throwable $error ) { return false; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } } /** * Require a coherent settings and purge grant for subsequent protected reads. * * Require one coherent settings-plus-purge decision for retention reductions. * * @return bool Whether the stricter current authority decision succeeds. */ public function require_purge(): bool { $this->capabilities = array( 'logged_ai_manage_settings', 'logged_ai_purge_data' ); return true === $this->read( static fn() => true ); } /** * Update the fixed native option through callbacks with a current preimage binding. * * Native callbacks remain active; stale rollback caches cannot fake a no-op. * * @param array|bool $value Proposed settings array or explicit uninstall-choice boolean. * @param callable $binding Callback rechecking the exact option preimage before native mutation. * * @return mixed Native option-update result, or an input, context or bound-write error. */ public function update( array|bool $value, callable $binding ): mixed { if ( is_bool( $value ) !== $this->uninstall_choice ) { return new \WP_Error( 'input', 'Invalid value for the fixed option mutation.' ); } $sql = array(); if ( null !== $this->option_network ) { $sql[] = $this->db->remove_placeholder_escape( $this->db->prepare( "UPDATE `{$this->db->sitemeta}` SET `meta_value` = %s WHERE `site_id` = %d AND `meta_key` = %s", maybe_serialize( $value ), $this->option_network, $this->option ) ); $sql[] = $this->db->remove_placeholder_escape( $this->db->prepare( "INSERT INTO `{$this->db->sitemeta}` (`site_id`, `meta_key`, `meta_value`) VALUES (%d, %s, %s)", $this->option_network, $this->option, maybe_serialize( $value ) ) ); } else { foreach ( array( 'off', 'no' ) as $autoload ) { $sql[] = $this->db->remove_placeholder_escape( $this->db->prepare( "UPDATE `{$this->db->options}` SET `option_value` = %s, `autoload` = %s WHERE `option_name` = %s", maybe_serialize( $value ), $autoload, $this->option ) ); $sql[] = $this->db->remove_placeholder_escape( $this->db->prepare( "INSERT INTO `{$this->db->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE `option_name` = VALUES(`option_name`), `option_value` = VALUES(`option_value`), `autoload` = VALUES(`autoload`)", $this->option, maybe_serialize( $value ), $autoload ) ); } } return $this->write( function () use ( $value ) { if ( null !== $this->option_network ) { wp_cache_delete( $this->option_network . ':' . $this->option, 'site-options' ); wp_cache_delete( $this->option_network . ':notoptions', 'site-options' ); return $this->current() ? update_network_option( $this->option_network, $this->option, $value ) : self::changed(); } wp_cache_delete( $this->option, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); return $this->current() ? update_option( $this->option, $value, false ) : self::changed(); }, $binding, $sql ); } /** * Read bounded current option bytes and capture their raw storage fingerprint. * * Bounded current read, including a raw binding for explicit malformed-option repair. * * @return mixed Decoded value, null for absence, or an unavailable/configuration error. */ public function value(): mixed { $this->fingerprint = null; if ( ! $this->current() ) { return self::changed(); } if ( null !== $this->option_network ) { $rows = $this->db->get_results( $this->db->prepare( "SELECT CASE WHEN OCTET_LENGTH(meta_value)<=16384 THEN meta_value ELSE NULL END AS value, SHA2(meta_value,256) AS fingerprint FROM {$this->db->sitemeta} WHERE site_id=%d AND meta_key=%s LIMIT 2 FOR UPDATE", $this->option_network, $this->option ), ARRAY_A ); if ( ! is_array( $rows ) || count( $rows ) > 1 ) { return new \WP_Error( 'storage', 'Installation choice is unavailable or has duplicate saved rows.' ); } $row = $rows[0] ?? null; } else { $row = $this->db->get_row( $this->db->prepare( "SELECT CASE WHEN OCTET_LENGTH(option_value)<=16384 THEN option_value ELSE NULL END AS value, SHA2(option_value,256) AS fingerprint FROM {$this->db->options} WHERE option_name=%s LIMIT 1 FOR UPDATE", $this->option ), ARRAY_A ); } /** * Validate the selected settings row before absence and fingerprint handling from the untyped wpdb adapter. * * @var mixed $row */ if ( '' !== $this->db->last_error || ! $this->current() ) { return new \WP_Error( 'storage', 'Current settings could not be read in their original context. Retry in a fresh caller transaction if needed.' ); } if ( null === $row ) { $this->fingerprint = 'absent'; return null; } if ( ! is_array( $row ) || ! is_string( $row['fingerprint'] ?? null ) || ! preg_match( '/\A[0-9a-f]{64}\z/D', $row['fingerprint'] ) ) { return new \WP_Error( 'storage', 'Saved settings fingerprint is unavailable.' ); } $this->fingerprint = 'present:' . $row['fingerprint']; if ( ! is_string( $row['value'] ?? null ) ) { return new \WP_Error( 'config_bounds', 'Saved settings exceed their supported parsing bounds.' ); } $value = $row['value']; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decode bounded native option state with classes disabled and finite depth; callers validate the typed result before using it. Do not expose raw option bytes in parser warnings. return is_serialized( $value ) ? @unserialize( trim( $value ), array( 'allowed_classes' => false, 'max_depth' => 8, ) ) : $value; } /** * Return the storage binding captured by the immediately preceding value read. * * Pure binding from the immediately preceding current read. * * @return string|null Present/absent binding, or null before a usable binding is available. */ public function fingerprint(): ?string { return $this->fingerprint; } /** * Observe a bound read and optionally recheck its value after current authorization. * * $authorize=false verifies an already executed write before its audit; it * never authorizes another option write or a successful public result. * * @param-immediately-invoked-callable $read * * @param callable $read Immediately invoked callback returning the protected current value. * @param bool $authorize Whether this read must also make and recheck the current authority decision. * * @return mixed Read result, or an authority, context, mutation or storage error. */ public function read( callable $read, bool $authorize = true ): mixed { if ( ! $this->current() ) { return self::changed(); } $changed = false; $observe = function ( string $sql ) use ( &$changed ): string { $statement = trim( $sql ); $setup = false; if ( in_array( $statement, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 argument-free frames to permit only separate Scope permission-connection setup during the protected option read. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 24 ); foreach ( $trace as $index => $frame ) { if ( 'query' !== $frame['function'] || ! ( ( $frame['object'] ?? null ) instanceof \wpdb ) ) { continue; } $next = $trace[ $index + 1 ] ?? array(); $setup = $frame['object'] instanceof WorkerConnection && $frame['object'] !== $this->db && Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === $next['function']; break; } } if ( ! $setup && ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $statement ) ) { $changed = true; } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $result = $read(); $allowed = ! is_wp_error( $result ) && $this->current() && ( ! $authorize || ( $this->uninstall_choice ? $this->scope->valid_for_installation_purge() : $this->scope->valid_for_all( $this->capabilities ) ) ) && $this->current(); // A native capability callback can commit through a separate worker. // Repeat the bound read after that callback, within this observer; no // native authority callback follows the final data comparison. if ( $allowed && $authorize ) { $fresh = $read(); $allowed = ! is_wp_error( $fresh ) && $fresh === $result && $this->current(); } } catch ( \Throwable $error ) { $result = new \WP_Error( 'storage', 'Settings verification was interrupted.' ); $allowed = false; } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } if ( $changed || ! $this->current() ) { return self::changed(); } return is_wp_error( $result ) || $allowed ? $result : self::changed(); } /** * Observe native writes and recheck the selected fixed-option binding at execution. * * Existing native callbacks precede this terminal observer. Installed PHP is * trusted; this is not isolation against a later deliberately appended hook. * * @param-immediately-invoked-callable $write * @param-immediately-invoked-callable $binding * * @param callable $write Immediately invoked callback executing the native mutation. * @param callable|null $binding Optional current option-preimage check; null adds no option-specific binding callback. * @param array|null $expected_sql Optional exact permitted fixed-option statements; null skips the statement list check. * * @return mixed Write callback result, or an observed-context, binding or storage error. */ public function write( callable $write, ?callable $binding = null, ?array $expected_sql = null ): mixed { if ( ! $this->current() ) { return self::changed(); } $changed = false; $checking = false; $tables = array_filter( array( $this->db->options, $this->db->sitemeta ?? null ), 'is_string' ); $observe = function ( string $sql ) use ( $binding, $tables, $expected_sql, &$changed, &$checking ): string { /** * Captured reference may be true during recursive native binding queries. * * @var bool $checking */ $statement = trim( $sql ); $read = 1 === preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', $statement ); $connection = null; $original_sql = null; $next = array(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 24 live frames to compare original SQL and the actual connection before the guarded option write; no trace is emitted or persisted. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 24 ); foreach ( $trace as $index => $frame ) { if ( 'query' === $frame['function'] && ( $frame['object'] ?? null ) instanceof \wpdb ) { $connection = $frame['object']; $original_sql = $frame['args'][0] ?? null; $next = $trace[ $index + 1 ] ?? array(); break; } } if ( $connection instanceof \wpdb && is_string( $original_sql ) ) { $original_sql = $connection->remove_placeholder_escape( $original_sql ); } $setup = $connection instanceof WorkerConnection && $connection !== $this->db && Scope::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === $next['function'] && in_array( $statement, array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ), true ); if ( $original_sql !== $sql || ( ! $read && ! $setup && ( $changed || $checking || ! $this->current() ) ) ) { $changed = true; return ''; } if ( ! $read && ! $setup && null !== $binding && $connection === $this->db ) { foreach ( $tables as $table ) { if ( preg_match( '/\A(?:UPDATE|INSERT(?:\s+IGNORE)?\s+INTO|REPLACE\s+INTO|DELETE\s+FROM)\s+`?' . preg_quote( $table, '/' ) . '`?\s/i', $statement ) ) { if ( null !== $expected_sql && ! in_array( $sql, $expected_sql, true ) ) { $changed = true; return ''; } $checking = true; try { $valid = $this->read( static fn() => true === $binding() ? true : new \WP_Error( 'stale', 'Settings changed in a native save hook.' ) ); } finally { $checking = false; } if ( is_wp_error( $valid ) || $changed || ! $this->current() ) { $changed = true; return ''; } break; } } } return $sql; }; add_filter( 'query', $observe, PHP_INT_MAX ); try { $result = $write(); } catch ( \Throwable $error ) { $result = new \WP_Error( 'storage', 'Settings write was interrupted. Inspect current settings before retrying.' ); } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } return $changed || ! $this->current() ? self::changed() : $result; } }