array( 'id', 'kind', 'status', 'body', 'created', 'updated', 'revision' ), 'avai_locks' => array( 'name', 'owner', 'expires' ), 'avai_work' => array( 'id', 'scan_id', 'root_id', 'path', 'identity', 'kind', 'state', 'cursor', 'attempts' ), ); /** Inspect without enumerating another plugin's private values. */ public static function ownedOption( string $name ): bool { if ( in_array( $name, self::OPTIONS, true ) || preg_match( '/^antivirus_ai_(?:ref|repository)_[a-f0-9]{64}$/D', $name ) ) { return true; } if ( ! preg_match( '/^_transient_(?:timeout_)?(.+)$/D', $name, $match ) ) { return false; } return self::ownedTransient( $match[1] ); } public static function ownedTransient( string $name ): bool { if ( 'antivirus_ai_pro_country_integrity' === $name || preg_match( '/^avai_country_event_[A-Z]{2}_(?:observe|block)$/D', $name ) ) { return true; } return 'antivirus_ai_pro_pair_proof' === $name || 1 === preg_match( '/^(?:antivirus_ai_(?:notice|pro_activation_complete|pro_setup_form|pro_setup_welcome|setup_form|setup_welcome|pro_health|welcome_next_notice|welcome_popup|welcome_redirect)_(?:0|[1-9][0-9]*)|antivirus_ai_task_[1-9][0-9]*_[1-9][0-9]*|avai_recent_[a-f0-9]{64}|avai_totp_pending_[1-9][0-9]*|avai_fw_[a-f0-9]{24})$/D', $name ); } /** Remove every argument variant of our hooks; preserve all other cron entries. */ public static function stopHooks(): void { foreach ( self::HOOKS as $hook ) { $result = wp_unschedule_hook( $hook, true ); if ( is_wp_error( $result ) || false === $result ) { throw new \RuntimeException( 'Scheduled Shieldlume Security work could not be stopped. Restore cron storage and retry deletion.' ); } } } private static function checked( $db, mixed $result ): mixed { if ( false === $result || ! empty( $db->last_error ) ) { throw new \RuntimeException( 'The local removal database operation failed. Remaining records and removal authorization are retained for review.' ); } return $result; } private static function options( $db, bool $lock = false ): array { $patterns = array( 'antivirus_ai_', '_transient_antivirus_ai_', '_transient_timeout_antivirus_ai_', '_transient_avai_', '_transient_timeout_avai_' ); $names = array(); foreach ( $patterns as $prefix ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- A fresh, bounded inventory is essential to reviewed deletion; identifiers use wpdb identifier placeholders, patterns are server-owned and all names are separately allowlisted. $rows = self::checked( $db, $db->get_col( $db->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s ORDER BY option_name LIMIT 20001' . ( $lock ? ' FOR UPDATE' : '' ), $db->options, $db->esc_like( $prefix ) . '%' ) ) ); if ( count( $rows ) > 20000 ) { throw new \RuntimeException( 'The owned cache inventory exceeds the reviewed cleanup limit. Ask your host for a controlled export and cleanup.' ); } foreach ( $rows as $name ) { if ( ! self::ownedOption( $name ) ) { throw new \RuntimeException( 'An unrecognized Shieldlume Security option or cache record requires host review. No automatic removal is permitted.' ); } $names[] = $name; } } sort( $names ); return $names; } /** Preserve a pending off-site withdrawal even if its local credential is absent. */ private static function advisorPrerequisite( mixed $settings ): void { if ( ! is_array( $settings ) ) { throw new \RuntimeException( 'Pro settings are unreadable. Review them before removing local data.' ); } if ( ! array_key_exists( 'advisor_grant', $settings ) || array() === $settings['advisor_grant'] ) { return; } $grant = $settings['advisor_grant']; $keys = array( 'id', 'revision', 'context', 'binding', 'enabled', 'pending', 'created_at' ); if ( ! is_array( $grant ) || count( $grant ) !== count( $keys ) || array_diff( $keys, array_keys( $grant ) ) || ! is_string( $grant['id'] ) || ! preg_match( '/^[a-f0-9]{32}$/D', $grant['id'] ) || 'advisor-1' !== $grant['revision'] || ! is_bool( $grant['context'] ) || ! is_string( $grant['binding'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $grant['binding'] ) || ! is_bool( $grant['enabled'] ) || ! is_bool( $grant['pending'] ) || ! is_int( $grant['created_at'] ) || $grant['created_at'] < 1 || $grant['enabled'] || $grant['pending'] ) { throw new \RuntimeException( 'AI Advisor permission is enabled, awaiting withdrawal, or unreadable. Reinstall Pro if needed and finish stopping AI sharing before deleting its retry state.' ); } } private static function localPrerequisites( bool $deleting ): void { global $wpdb; if ( is_multisite() || 'wpdb' !== get_class( $wpdb ) || ! extension_loaded( 'mysqli' ) || wp_using_ext_object_cache() ) { throw new \RuntimeException( 'Automatic removal requires single-site WordPress, its native database connection and no persistent object cache. Ask your host for scoped cleanup on this configuration.' ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; wp_clean_plugins_cache( false ); foreach ( get_plugins() as $file => $plugin ) { $requires = array_map( 'trim', explode( ',', $plugin['RequiresPlugins'] ?? '' ) ); if ( 'antivirus-ai-pro/antivirus-ai-pro.php' === $file || ( ANTIVIRUS_AI_BASENAME !== $file && in_array( 'shieldlume-security', $requires, true ) ) ) { throw new \RuntimeException( 'Stop and erase Pro monitoring/audit, disconnect Pro, and delete Pro and any dependent plugin before arming Free data removal.' ); } } // This is the configured installation root, not this plugin's asset location. // Detect partial or symlinked Pro installations even when get_plugins() has no header. $proDirectory = trailingslashit( InstallationPaths::plugins() ) . 'antivirus-ai-pro'; if ( file_exists( $proDirectory ) || is_link( $proDirectory ) ) { throw new \RuntimeException( 'The Pro plugin directory remains installed. Delete Pro first; Free cannot erase data it still depends on.' ); } if ( $deleting && is_plugin_active( ANTIVIRUS_AI_BASENAME ) ) { throw new \RuntimeException( 'Deactivate Shieldlume Security Free before using WordPress Delete.' ); } if ( array() !== DataRemoval::read( 'antivirus_ai_welcome', array() ) ) { throw new \RuntimeException( 'Welcome sharing or a withdrawal retry remains. Reactivate and finish Stop welcome sharing before deleting its credential.' ); } $connection = DataRemoval::read( 'antivirus_ai_pro_connection', '' ); // A disconnect marker contains only a random race fence, never credentials. $disconnected = is_string( $connection ) && 1 === preg_match( '/^avp-disconnected:v1:[a-f0-9]{64}$/D', $connection ); if ( '' !== $connection && false !== $connection && ! $disconnected ) { throw new \RuntimeException( 'A Pro connection credential remains. Reinstall Pro if needed and disconnect it explicitly before removing its local data.' ); } self::advisorPrerequisite( DataRemoval::read( 'antivirus_ai_pro_settings', array() ) ); $monitoring = DataRemoval::read( 'antivirus_ai_pro_monitoring', array() ); if ( ! is_array( $monitoring ) || ( $monitoring && ( ! isset( $monitoring['enabled'], $monitoring['pending_delete'] ) || ! is_bool( $monitoring['enabled'] ) || ! is_bool( $monitoring['pending_delete'] ) || $monitoring['enabled'] || $monitoring['pending_delete'] ) ) ) { throw new \RuntimeException( 'Pro monitoring is enabled, awaiting erasure, or unreadable. Complete Stop and erase monitoring before deleting its retry state.' ); } $hosted = DataRemoval::read( 'antivirus_ai_pro_hosted_monitoring', array() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( ! is_array( $hosted ) ) { throw new \RuntimeException( __( 'Homepage monitoring state is unreadable. Reinstall Pro and finish withdrawal.', 'shieldlume-security' ) ); } foreach ( array( 'uptime', 'reputation' ) as $feature ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( array_key_exists( $feature, $hosted ) && ( ! is_array( $hosted[ $feature ] ) || false !== ( $hosted[ $feature ]['enabled'] ?? null ) || false !== ( $hosted[ $feature ]['pending_delete'] ?? null ) ) ) { throw new \RuntimeException( __( 'Stop and erase hosted homepage monitoring before deleting its retry state.', 'shieldlume-security' ) ); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( ! empty( $hosted['result'][ $feature ]['enabled'] ) ) { throw new \RuntimeException( __( 'Hosted monitoring remains enabled in the service account. Reinstall Pro and finish Stop and erase before removing its local records.', 'shieldlume-security' ) ); } } $country = DataRemoval::read( 'antivirus_ai_pro_country_data', array() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( ! is_array( $country ) || $country ) { throw new \RuntimeException( __( 'Reinstall Pro and stop country downloads to erase licensed country data before removal.', 'shieldlume-security' ) ); } $uploads = wp_upload_dir( null, false ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( ! is_array( $uploads ) || ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) || ! is_string( $uploads['basedir'] ) ) { throw new \RuntimeException( __( 'The upload directory could not be checked for retained country data.', 'shieldlume-security' ) ); } $country_directory = rtrim( $uploads['basedir'], '/' ) . '/shieldlume-country'; if ( file_exists( $country_directory ) || is_link( $country_directory ) ) { PathGuard::noLinks( $country_directory ); $country_entries = ! is_link( $country_directory ) && is_dir( $country_directory ) ? scandir( $country_directory ) : false; if ( is_array( $country_entries ) && in_array( '.country-write.lock', $country_entries, true ) ) { $country_lock = PathGuard::resolve( $country_directory, '.country-write.lock' ); $country_stat = lstat( $country_lock ); // The retained empty mutex is not licensed data. Never accept a // symlink, hardlink, payload, special file or loose permissions here. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( ! is_array( $country_stat ) || 0100000 !== ( $country_stat['mode'] & 0170000 ) || 1 !== $country_stat['nlink'] || 0 !== $country_stat['size'] || 0600 !== ( $country_stat['mode'] & 0777 ) ) { throw new \RuntimeException( __( 'The retained country storage lock is not an empty protected regular file. Ask your host to review it before removal.', 'shieldlume-security' ) ); } $country_entries = array_diff( $country_entries, array( '.country-write.lock' ) ); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Plain-text cleanup failure is escaped by the existing removal and uninstall handlers at final output. if ( false === $country_entries || array_diff( $country_entries, array( '.', '..' ) ) ) { throw new \RuntimeException( __( 'Country data files remain in the uploads directory. Reinstall Pro and stop downloads, or ask your host to review the retained files before removal.', 'shieldlume-security' ) ); } } $early = DataRemoval::read( 'antivirus_ai_early_firewall', array() ); if ( ! is_array( $early ) ) { throw new \RuntimeException( 'Early-filter state is unreadable. Ask your host to review its private files and prepend configuration.' ); } if ( defined( 'ANTIVIRUS_AI_EARLY_DIR' ) ) { $directory = EarlyStorage::directory(); $entries = scandir( $directory ); if ( false === $entries || array_diff( $entries, array( '.', '..' ) ) ) { throw new \RuntimeException( 'Private early-filter files remain. Ask your host to remove every configured prepend reference and review these files safely. The plugin never deletes a host bootstrap or edits host configuration.' ); } } elseif ( $early && ! ( 2 === count( $early ) && is_bool( $early['deactivated'] ?? null ) && 'off' === ( $early['mode'] ?? null ) ) ) { throw new \RuntimeException( 'Early filtering was configured but its private directory cannot be verified. Host review is required before data removal.' ); } } private static function optionDigest( $db, array $names ): string { $context = hash_init( 'sha256' ); foreach ( $names as $name ) { if ( in_array( $name, array( DataRemoval::FENCE, DataRemoval::INTENT, DataRemoval::WARNING ), true ) ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Compare exact owned raw values before and inside the final transaction; a late welcome credential or changed consent must never be erased using a stale preflight. $value = self::checked( $db, $db->get_var( $db->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $db->options, $name ) ) ); if ( null === $value ) { throw new \RuntimeException( 'Owned option inventory changed during review. Retry after active requests finish.' ); } hash_update( $context, serialize( array( $name, $value ) ) ); } return hash_final( $context ); } private static function recoveryFiles( array $owned ): array { if ( ! defined( 'ANTIVIRUS_AI_RECOVERY_DIR' ) ) { return array( 'directory' => '', 'files' => array() ); } $directory = RecoveryEvidence::directory(); $entries = scandir( $directory ); if ( false === $entries || count( $entries ) > 10002 ) { throw new \RuntimeException( 'Private recovery storage cannot be inventoried safely within the reviewed limit.' ); } $files = array(); foreach ( $entries as $name ) { if ( '.' === $name || '..' === $name ) { continue; } if ( ! preg_match( '/^p_[a-f0-9]{32}\.evidence$/D', $name ) ) { throw new \RuntimeException( 'Private recovery storage contains unknown or incomplete files. Preserve them and ask your host for review before removal.' ); } $id = substr( $name, 0, -9 ); if ( ! isset( $owned[ $id ] ) ) { throw new \RuntimeException( 'A recovery file is not linked to this installation\'s repair journal. Preserve unknown, orphaned or other-site evidence for host review.' ); } $path = $directory . '/' . $name; clearstatcache( true, $path ); $stat = lstat( $path ); if ( ! is_array( $stat ) || 0100000 !== ( $stat['mode'] & 0170000 ) || 0600 !== ( $stat['mode'] & 0777 ) || 1 !== $stat['nlink'] || $stat['uid'] !== posix_geteuid() || $stat['size'] < 46 || $stat['size'] > 2097408 ) { throw new \RuntimeException( 'A private recovery entry is not a safe owned regular file. Host review is required.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read only the signature of an exact private owner-verified local evidence file, never a URL or filtered filesystem destination. if ( "AVAI1\n" !== file_get_contents( $path, false, null, 0, 6 ) ) { throw new \RuntimeException( 'A recovery file has an unrecognized format. Preserve it for host review.' ); } // Authentication binds this exact identifier to the hash recorded by this // site's journal. A same-owner private directory alone does not prove ownership. RecoveryEvidence::read( $id, $owned[ $id ] ); $files[ $name ] = array( 'dev' => $stat['dev'], 'ino' => $stat['ino'], 'size' => $stat['size'], 'mtime' => $stat['mtime'], 'sha256' => hash_file( 'sha256', $path ) ); } ksort( $files ); return array( 'directory' => $directory, 'files' => $files ); } private static function recoveryReference( array &$owned, mixed $id, mixed $hash ): void { if ( ! is_string( $id ) || ! preg_match( '/^p_[a-f0-9]{32}$/D', $id ) || ! is_string( $hash ) || ! preg_match( '/^[a-f0-9]{64}$/D', $hash ) || ( isset( $owned[ $id ] ) && $owned[ $id ] !== $hash ) ) { throw new \RuntimeException( 'A recovery journal binding is malformed or conflicting. Preserve the evidence for host review.' ); } $owned[ $id ] = $hash; } private static function tables( $db, bool $deleting, bool $lock = false ): array { $tables = array(); $count = 0; $owned = array(); $digests = array(); $cancelledScans = array(); foreach ( self::COLUMNS as $suffix => $expected ) { $table = $db->prefix . $suffix; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify exact server-owned table identity and engine before any deletion; no cached schema is safe here. $engine = self::checked( $db, $db->get_var( $db->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ) ); if ( null === $engine ) { continue; } if ( 'InnoDB' !== $engine ) { throw new \RuntimeException( 'Owned data must use transactional InnoDB tables for automatic removal. Ask your host for controlled cleanup.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- An exact column inventory prevents this version deleting an unknown future schema. $columns = self::checked( $db, $db->get_col( $db->prepare( 'SHOW COLUMNS FROM %i', $table ) ) ); if ( $columns !== $expected ) { throw new \RuntimeException( 'The local plugin schema differs from the reviewed removal version. Preserve it for a compatible cleanup.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bound and recheck the exact owned table; FOR UPDATE is used only inside the dedicated non-reconnecting removal transaction. $rows = self::checked( $db, $db->get_results( $db->prepare( 'SELECT * FROM %i ORDER BY %i LIMIT 100001' . ( $lock ? ' FOR UPDATE' : '' ), $table, 'avai_locks' === $suffix ? 'name' : 'id' ), ARRAY_A ) ); if ( count( $rows ) > 100000 ) { throw new \RuntimeException( 'The plugin record inventory exceeds the automatic removal limit. Ask your host for controlled export and cleanup.' ); } foreach ( $rows as $row ) { if ( 'avai_locks' === $suffix && $deleting && (int) $row['expires'] >= time() ) { throw new \RuntimeException( 'A Shieldlume Security work lease remains active. Wait for it to finish before retrying Delete.' ); } if ( 'avai_work' === $suffix && ! in_array( $row['state'], array( 'done', 'skipped', 'failed' ), true ) ) { // Cancelling preserves the queue as partial evidence. Only a matching, // validated cancellation makes its pending rows inactive; orphaned, // unknown and other unfinished work must still block removal. if ( 'pending' !== $row['state'] || ! isset( $cancelledScans[ $row['scan_id'] ] ) ) { throw new \RuntimeException( 'Scan work is unfinished. Reactivate and finish or cancel it before removing data.' ); } } if ( 'avai_objects' !== $suffix ) { continue; } if ( 'scan' === $row['kind'] && 'cancelled' === $row['status'] && preg_match( '/^s_[a-f0-9]{32}$/D', $row['id'] ) ) { $scan = json_decode( $row['body'], true ); if ( is_array( $scan ) && ( $scan['id'] ?? null ) === $row['id'] && 'cancelled' === ( $scan['status'] ?? null ) ) { $cancelledScans[ $row['id'] ] = true; } } $unfinished = in_array( $row['status'], array( 'queued', 'running', 'pending', 'authorized', 'backed_up', 'applying', 'verification_pending', 'health_pending', 'health_failed', 'needs_review' ), true ); $unfinished = $unfinished || ( 'analysis' === $row['kind'] && in_array( $row['status'], array( 'processing', 'retry' ), true ) ); if ( $unfinished || ( 'repair' === $row['kind'] && 'applied' === $row['status'] ) ) { throw new \RuntimeException( 'Unfinished work or a repair needing review remains. Resolve it before authorizing data removal.' ); } if ( 'pro_audit_state' === $row['id'] ) { $audit = json_decode( $row['body'], true ); if ( 'pro_audit' !== $row['kind'] || ! is_array( $audit ) || ! isset( $audit['enabled'], $audit['pending_delete'], $audit['confirmed'], $audit['queue'] ) || false !== $audit['enabled'] || false !== $audit['pending_delete'] || false !== $audit['confirmed'] || array() !== $audit['queue'] ) { throw new \RuntimeException( 'Pro audit delivery is enabled, pending erasure, or unreadable. Complete Stop and erase audit before removing its retry state.' ); } } if ( in_array( $row['kind'], array( 'repair', 'content_repair' ), true ) && ! defined( 'ANTIVIRUS_AI_RECOVERY_DIR' ) ) { throw new \RuntimeException( 'Repair records exist without a verifiable recovery directory. Ask your host to locate and preserve the recovery material first.' ); } if ( in_array( $row['kind'], array( 'repair', 'content_repair' ), true ) ) { $plan = json_decode( $row['body'], true ); if ( ! is_array( $plan ) || ( $plan['id'] ?? null ) !== $row['id'] ) { throw new \RuntimeException( 'A repair journal cannot be read safely. Retain its recovery files for host review.' ); } if ( 'content_repair' === $row['kind'] ) { self::recoveryReference( $owned, $plan['before_id'] ?? null, $plan['before_hash'] ?? null ); self::recoveryReference( $owned, $plan['after_id'] ?? null, $plan['after_hash'] ?? null ); } elseif ( isset( $plan['recovery'] ) ) { if ( ! is_array( $plan['recovery'] ) || ( $plan['recovery']['id'] ?? null ) !== $row['id'] || ( $plan['recovery']['sha256'] ?? null ) !== ( $plan['expected_hash'] ?? null ) ) { throw new \RuntimeException( 'The repair recovery binding is inconsistent. Preserve it for host review.' ); } self::recoveryReference( $owned, $row['id'], $plan['expected_hash'] ?? null ); } } } $digests[ $table ] = hash( 'sha256', serialize( $rows ) ); $count += count( $rows ); $tables[] = $table; } ksort( $owned ); return array( 'tables' => $tables, 'record_count' => $count, 'recovery' => $owned, 'digests' => $digests ); } public static function inspect( bool $deleting = false ): array { global $wpdb; self::localPrerequisites( $deleting ); $names = self::options( $wpdb ); $tables = self::tables( $wpdb, $deleting ); return array_merge( $tables, self::recoveryFiles( $tables['recovery'] ), array( 'options' => $names, 'option_count' => count( $names ), 'option_digest' => self::optionDigest( $wpdb, $names ) ) ); } private static function updateCache(): void { $updates = get_site_transient( 'update_plugins' ); if ( false === $updates ) { return; } if ( ! is_object( $updates ) ) { throw new \RuntimeException( 'The shared plugin update cache is unreadable. It was preserved for review.' ); } $changed = false; foreach ( array( 'response', 'no_update', 'checked' ) as $key ) { if ( ! isset( $updates->$key ) ) { continue; } if ( ! is_array( $updates->$key ) ) { throw new \RuntimeException( 'The shared plugin update cache has an unknown format. It was preserved.' ); } foreach ( array( ANTIVIRUS_AI_BASENAME, 'antivirus-ai-pro/antivirus-ai-pro.php' ) as $file ) { if ( array_key_exists( $file, $updates->$key ) ) { unset( $updates->{$key}[ $file ] ); $changed = true; } } } if ( $changed && ! set_site_transient( 'update_plugins', $updates ) ) { throw new \RuntimeException( 'The plugin-owned update entries could not be removed. All other updater entries are preserved.' ); } } /** Authorization and real Delete checks belong to DataRemoval::uninstall(). */ public static function run( array $state ): void { $before = self::inspect( true ); DataRemoval::fence( $state ); $directoryLock = null; $db = null; $transaction = false; try { if ( '' !== $before['directory'] ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Match Recovery's owner-only native directory lock; filesystem transports cannot provide these identity and locking guarantees. $directoryLock = fopen( $before['directory'], 'r' ); if ( ! $directoryLock || ! flock( $directoryLock, LOCK_EX | LOCK_NB ) ) { throw new \RuntimeException( 'Recovery storage is busy. Its records and removal authorization were retained; retry Delete after work finishes.' ); } } $review = self::inspect( true ); if ( $review['files'] !== $before['files'] || $review['tables'] !== $before['tables'] ) { throw new \RuntimeException( 'The private inventory changed during review. Nothing further will be removed; retry after investigating active work.' ); } self::stopHooks(); self::updateCache(); $db = new RemovalDatabase(); foreach ( array( $db->options, $db->usermeta ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WordPress tables must support the same rollback boundary; only their exact plugin-owned rows are deleted. $engine = self::checked( $db, $db->get_var( $db->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ) ); if ( 'InnoDB' !== $engine ) { throw new \RuntimeException( 'WordPress options and user metadata must use InnoDB for this reviewed removal.' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Use one non-reconnecting transaction, with exact-row and gap locks, for the reviewed database cleanup. self::checked( $db, $db->query( 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- See transaction justification above. self::checked( $db, $db->query( 'START TRANSACTION' ) ); $transaction = true; $names = self::options( $db, true ); if ( ! hash_equals( $review['option_digest'], self::optionDigest( $db, $names ) ) ) { throw new \RuntimeException( 'Owned settings, credentials or consent changed during the final removal review. No recovery files were removed.' ); } $locked = self::tables( $db, true, true ); if ( $locked['digests'] !== $review['digests'] || $locked['recovery'] !== $review['recovery'] ) { throw new \RuntimeException( 'Plugin records changed during the final removal review. The database transaction will be rolled back.' ); } if ( self::recoveryFiles( $locked['recovery'] ) !== array( 'directory' => $review['directory'], 'files' => $review['files'] ) ) { throw new \RuntimeException( 'Recovery files changed during the final review. They require host inspection.' ); } foreach ( $review['files'] as $name => $identity ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Exact allowlisted, owner-verified regular evidence files only, under the same locked canonical directory; no recursion, symlinks or host configuration deletion. if ( ! unlink( $review['directory'] . '/' . $name ) ) { throw new \RuntimeException( 'A recovery file could not be removed. Some earlier files may already be removed; database records and retry authorization remain.' ); } } if ( $directoryLock && ! fsync( $directoryLock ) ) { throw new \RuntimeException( 'Recovery removal durability could not be verified. Database records remain; already removed files cannot be restored by cancellation.' ); } foreach ( $names as $name ) { if ( in_array( $name, array( DataRemoval::FENCE, DataRemoval::INTENT ), true ) ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete only names independently matched against the fixed option/transient allowlist, inside the dedicated transaction. self::checked( $db, $db->query( $db->prepare( 'DELETE FROM %i WHERE option_name=%s', $db->options, $name ) ) ); } $users = array(); foreach ( array( 'antivirus_ai_theme', 'antivirus_ai_workspace_display', '_antivirus_ai_journey_' . get_current_blog_id(), '_antivirus_ai_review_' . get_current_blog_id(), '_antivirus_ai_saved_views_' . get_current_blog_id() ) as $key ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Exact plugin-owned meta keys only; retain users, content, sessions and all other plugin metadata. $users = array_merge( $users, self::checked( $db, $db->get_col( $db->prepare( 'SELECT user_id FROM %i WHERE meta_key=%s FOR UPDATE', $db->usermeta, $key ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Same exact reviewed metadata boundary as above. self::checked( $db, $db->query( $db->prepare( 'DELETE FROM %i WHERE meta_key=%s', $db->usermeta, $key ) ) ); } foreach ( $locked['tables'] as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- These are the three exact schema-verified plugin-owned tables; their rows share this transaction. self::checked( $db, $db->query( $db->prepare( 'DELETE FROM %i', $table ) ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Commit only after every exact database deletion succeeds; filesystem deletion cannot be rolled back and is disclosed separately. self::checked( $db, $db->query( 'COMMIT' ) ); $transaction = false; foreach ( $names as $name ) { wp_cache_delete( $name, 'options' ); } wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); foreach ( array_unique( $users ) as $user ) { wp_cache_delete( (int) $user, 'user_meta' ); } foreach ( $locked['tables'] as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- Drop only the three exact reviewed empty owned tables after committing row cleanup; keep a durable fence if this irreversible stage is interrupted. self::checked( $db, $db->query( $db->prepare( 'DROP TABLE %i', $table ) ) ); } $remaining = self::options( $db ); if ( array_diff( $remaining, array( DataRemoval::FENCE, DataRemoval::INTENT ) ) || self::tables( $db, true )['tables'] || self::recoveryFiles( array() )['files'] ) { throw new \RuntimeException( 'The final inventory changed. Remaining records and the removal fence were retained for review.' ); } DataRemoval::completed(); } finally { if ( $db ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Roll back incomplete database cleanup on the same non-reconnecting connection; no mutation is retried on another connection. if ( $transaction ) { $db->query( 'ROLLBACK' ); } $db->close(); } if ( $directoryLock ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Release the native Recovery-compatible directory lock after all removal checks. flock( $directoryLock, LOCK_UN ); fclose( $directoryLock ); } } } }