prepare( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id=%d AND meta_key='logged_ai_delete_all_on_uninstall' LIMIT 1", get_main_network_id() ) : "SELECT option_value FROM {$wpdb->options} WHERE option_name='logged_ai_delete_all_on_uninstall' LIMIT 1"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Local variable holds only fixed native uninstall-choice SQL; network ID is prepared and metadata is reread after the lease. Reread explicit installation-wide deletion consent after acquiring the mutation lease; cached consent cannot authorize irreversible cleanup. $choice_bytes = $wpdb->get_var( $choice_sql ); if ( '' !== $wpdb->last_error ) { return new WP_Error( 'uninstall_choice', 'The current explicit uninstall choice could not be verified.' ); } $current_choice = null === $choice_bytes ? false : maybe_unserialize( $choice_bytes ); if ( true !== $current_choice && '1' !== $current_choice ) { return true; } $logged_ai_files_removed = \LoggedAI\Exports::uninstall_owned_files(); if ( is_wp_error( $logged_ai_files_removed ) || ! $logged_ai_files_removed ) { throw new RuntimeException( 'Logged.ai private export files could not all be removed. Database metadata is preserved for retry.' ); } $logged_ai_clean_site = static function (): bool|WP_Error { global $wpdb; /** * Native WordPress database connection, including supported worker subclasses. * * @var \wpdb $wpdb */ $hooks = array( 'logged_ai_request_retention', 'logged_ai_retention', 'logged_ai_privacy_batch', 'logged_ai_export_worker', 'logged_ai_export_cleanup', 'logged_ai_alert_worker', 'logged_ai_alert_batch', 'logged_ai_network_alert_batch', 'logged_ai_migrate', 'logged_ai_network_retention', 'logged_ai_network_privacy_batch', 'logged_ai_network_export_worker', 'logged_ai_pro_assessments', 'logged_ai_automatic_cleanup', 'logged_ai_automatic_worker', 'logged_ai_report_worker', 'logged_ai_incident_worker', 'logged_ai_advanced_alert_sources', 'logged_ai_advanced_alert_deliveries' ); $options = array( 'logged_ai_schema_version', 'logged_ai_request_schema_version', 'logged_ai_collection_health', 'logged_ai_collection', 'logged_ai_request_source', 'logged_ai_observer_selftest', 'logged_ai_settings', 'logged_ai_export_schema_version', 'logged_ai_export_generation', 'logged_ai_alerts', 'logged_ai_alert_cleanup_pending', 'logged_ai_migration_site', 'logged_ai_migrations', 'logged_ai_health_alerts', 'logged_ai_health_exports', 'logged_ai_health_retention', 'logged_ai_health_request_retention', 'logged_ai_health_response', 'logged_ai_health_network_retention', 'logged_ai_health_network_exports', 'logged_ai_health_network_alerts', 'logged_ai_pro_initialized', 'logged_ai_pro_health', 'logged_ai_report_health', 'logged_ai_pro_commercial_v1', 'logged_ai_pro_commercial_instance', 'logged_ai_pro_local_access_policy' ); $caps = array( 'logged_ai_view_events', 'logged_ai_view_sensitive_context', 'logged_ai_purge_data', 'logged_ai_purge_network_data', 'logged_ai_export_events', 'logged_ai_manage_response', 'logged_ai_manage_settings', 'logged_ai_manage_alerts', 'logged_ai_view_network_events', 'logged_ai_export_network_events', 'logged_ai_manage_network_settings', 'logged_ai_manage_investigations' ); foreach ( $hooks as $hook ) { $result = in_array( $hook, array( 'logged_ai_incident_worker', 'logged_ai_advanced_alert_sources', 'logged_ai_advanced_alert_deliveries' ), true ) ? wp_unschedule_hook( $hook, true ) : wp_clear_scheduled_hook( $hook, array(), true ); if ( is_wp_error( $result ) || false === $result ) { return new WP_Error( 'uninstall_cron', 'An owned schedule could not be removed.' ); } } foreach ( $options as $option ) { delete_option( $option ); } // These preferences use native site-scoped user options, including users without a current role. $preference_names = array( $wpdb->get_blog_prefix() . 'logged_ai_hide_pro_suggestions', $wpdb->get_blog_prefix() . 'logged_ai_hide_pro_previews' ); $preference_cursor = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate bounded physical rows for exactly two owned current-site preference keys; user membership and cached user options cannot prove their inventory. $preferences = $wpdb->get_results( $wpdb->prepare( "SELECT umeta_id FROM {$wpdb->usermeta} WHERE BINARY meta_key IN (%s,%s) AND umeta_id>%d ORDER BY umeta_id LIMIT 100", $preference_names[0], $preference_names[1], $preference_cursor ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $preferences ) ) { return new WP_Error( 'uninstall_preferences', 'Owned display preferences could not be inspected.' ); } foreach ( $preferences as $record ) { $preference_cursor = (int) $record['umeta_id']; // Delete the exact enumerated row through WordPress; case-insensitive key deletion could remove unrelated variants. delete_metadata_by_mid( 'user', $preference_cursor ); } // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- Each bounded native page changes as preferences are removed; continue only while a full current page was read. } while ( 100 === count( $preferences ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify physical absence of both exact current-site preference keys after native deletion; cached misses cannot establish successful explicit uninstall. $preferences_left = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE BINARY meta_key IN (%s,%s)", $preference_names[0], $preference_names[1] ) ); if ( '' !== $wpdb->last_error || null === $preferences_left || 0 !== (int) $preferences_left ) { return new WP_Error( 'uninstall_preferences', 'Owned display preference cleanup could not be verified.' ); } foreach ( wp_roles()->role_objects as $role ) { foreach ( $caps as $capability ) { $role->remove_cap( $capability ); } } // Bound each direct read and independently verify native per-user writes, bypassing stale caches. $key = $wpdb->get_blog_prefix() . 'capabilities'; $cursor = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate bounded persisted per-user capability rows by native ID, then remove only owned grants through WP_User and verify physical storage. $users = $wpdb->get_results( $wpdb->prepare( "SELECT umeta_id,user_id,meta_value FROM {$wpdb->usermeta} WHERE meta_key=%s AND umeta_id>%d ORDER BY umeta_id LIMIT 100", $key, $cursor ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $users ) ) { return new WP_Error( 'uninstall_grants', 'Per-user capability cleanup could not be inspected.' ); } foreach ( $users as $record ) { $cursor = (int) $record['umeta_id']; $grants = maybe_unserialize( $record['meta_value'] ); if ( ! is_array( $grants ) ) { return new WP_Error( 'uninstall_grants', 'Persisted user capability storage is unavailable.' ); } if ( ! array_intersect( $caps, array_keys( $grants ) ) ) { continue; } clean_user_cache( (int) $record['user_id'] ); wp_cache_delete( (int) $record['user_id'], 'user_meta' ); $user = new WP_User( (int) $record['user_id'], '', get_current_blog_id() ); foreach ( $caps as $capability ) { if ( array_key_exists( $capability, $user->caps ) ) { $user->remove_cap( $capability ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the actual per-user row after native capability removal; a warmed user-meta cache cannot prove owned grants are gone. $stored = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->usermeta} WHERE umeta_id=%d", $cursor ) ); if ( '' !== $wpdb->last_error ) { return new WP_Error( 'uninstall_grants', 'Per-user cleanup verification failed.' ); } $remaining = null === $stored ? array() : maybe_unserialize( $stored ); if ( ! is_array( $remaining ) || array_intersect( $caps, array_keys( $remaining ) ) ) { return new WP_Error( 'uninstall_grants', 'An owned per-user capability remains.' ); } } // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- This bounded collection changes during each page/cleanup step. Its current native array count is an O(1) continuation check and must not be hoisted outside the loop. } while ( 100 === count( $users ) ); $marks = implode( ',', array_fill( 0, count( $options ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Native wpdb options supplies the table. marks is count-matched generated %s placeholders for the local literal owned-option list; every option name is separately prepared. Verify physical absence of every fixed owned site option after native deletion; cached option misses cannot establish completed uninstall. Generated %s count equals the fixed owned-option list length and spread arguments use that same list. $left = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name IN ({$marks})", ...$options ) ); if ( '' !== $wpdb->last_error || null === $left || 0 !== (int) $left ) { return new WP_Error( 'uninstall_options', 'Owned settings cleanup could not be verified.' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify actual stored role capabilities after native role updates; cached role objects cannot prove all owned grants were removed. $role_bytes = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", $wpdb->get_blog_prefix() . 'user_roles' ) ); if ( '' !== $wpdb->last_error ) { return new WP_Error( 'uninstall_grants', 'Role cleanup verification failed.' ); } $roles = null === $role_bytes ? array() : maybe_unserialize( $role_bytes ); if ( ! is_array( $roles ) ) { return new WP_Error( 'uninstall_grants', 'Persisted role storage is unavailable.' ); } foreach ( $roles as $role ) { if ( is_array( $role['capabilities'] ?? null ) && array_intersect( $caps, array_keys( $role['capabilities'] ) ) ) { return new WP_Error( 'uninstall_grants', 'An owned role capability remains.' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the persisted cron row after native unscheduling; cached cron state cannot prove owned jobs are gone. $cron_bytes = $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name='cron'" ); if ( '' !== $wpdb->last_error ) { return new WP_Error( 'uninstall_cron', 'Schedule cleanup verification failed.' ); } $cron = null === $cron_bytes ? array() : maybe_unserialize( $cron_bytes ); if ( ! is_array( $cron ) ) { return new WP_Error( 'uninstall_cron', 'Persisted schedule storage is unavailable.' ); } foreach ( $cron as $entries ) { if ( is_array( $entries ) && array_intersect( $hooks, array_keys( $entries ) ) ) { return new WP_Error( 'uninstall_cron', 'An owned schedule remains.' ); } } return true; }; $logged_ai_rules_removed = \LoggedAI\RequestRuleCleanup::uninstall(); if ( is_wp_error( $logged_ai_rules_removed ) ) { return $logged_ai_rules_removed; } $logged_ai_import_removed = \LoggedAI\RequestsImportPrivacy::uninstall(); if ( is_wp_error( $logged_ai_import_removed ) ) { return $logged_ai_import_removed; } $logged_ai_advanced_removed = \LoggedAI\AdvancedAlertMaintenance::uninstall_owned_tables(); if ( is_wp_error( $logged_ai_advanced_removed ) ) { return $logged_ai_advanced_removed; } $logged_ai_reports_removed = \LoggedAI\ReportPrivacy::uninstall_owned_tables(); if ( is_wp_error( $logged_ai_reports_removed ) ) { return $logged_ai_reports_removed; } $logged_ai_incident_runner_removed = \LoggedAI\IncidentRunnerPrivacy::uninstall_owned_tables(); if ( is_wp_error( $logged_ai_incident_runner_removed ) ) { return $logged_ai_incident_runner_removed; } $logged_ai_incident_removed = \LoggedAI\ScopedRequestCasePrivacy::uninstall_owned_table( static function (): bool { global $wpdb; /** * Native WordPress database connection, including supported worker subclasses. * * @var \wpdb $wpdb */ $sql = is_multisite() ? $wpdb->prepare( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id=%d AND meta_key='logged_ai_delete_all_on_uninstall' LIMIT 1", get_main_network_id() ) : "SELECT option_value FROM {$wpdb->options} WHERE option_name='logged_ai_delete_all_on_uninstall' LIMIT 1"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Only the fixed native uninstall-choice branches populate this callback query; main-network ID is prepared. Recheck the explicit native deletion-consent row inside held request-case cleanup; cached consent cannot authorize a later cleanup boundary. $choice = $wpdb->get_var( $sql ); return '' === $wpdb->last_error && in_array( $choice, array( '1', 'b:1;' ), true ); } ); if ( is_wp_error( $logged_ai_incident_removed ) ) { return $logged_ai_incident_removed; } $logged_ai_incident_removed = \LoggedAI\IncidentPrivacy::uninstall_owned_tables(); if ( is_wp_error( $logged_ai_incident_removed ) ) { return $logged_ai_incident_removed; } $logged_ai_baseline_removed = \LoggedAI\BaselinePrivacy::uninstall_owned_tables(); if ( is_wp_error( $logged_ai_baseline_removed ) ) { return $logged_ai_baseline_removed; } $logged_ai_derived_removed = \LoggedAI\Derived::uninstall_owned_table(); if ( is_wp_error( $logged_ai_derived_removed ) ) { return $logged_ai_derived_removed; } if ( is_multisite() ) { $logged_ai_cursor = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate current installation site IDs in bounded keyset pages for explicit all-site cleanup; cached/offset inventory could omit changed membership. $logged_ai_sites = $wpdb->get_col( $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id>%d ORDER BY blog_id LIMIT 100", $logged_ai_cursor ) ); /** * Validate site IDs before advancing the Free uninstall cursor from the untyped wpdb adapter. * * @var mixed $logged_ai_sites */ if ( '' !== $wpdb->last_error || ! is_array( $logged_ai_sites ) ) { return new WP_Error( 'uninstall_sites', 'Site cleanup enumeration failed.' ); } foreach ( $logged_ai_sites as $logged_ai_site ) { $logged_ai_cursor = (int) $logged_ai_site; switch_to_blog( (int) $logged_ai_site ); try { $site_result = $logged_ai_clean_site(); if ( is_wp_error( $site_result ) ) { return $site_result; } } finally { restore_current_blog(); } } // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- This bounded collection changes during each page/cleanup step. Its current native array count is an O(1) continuation check and must not be hoisted outside the loop. } while ( 100 === count( $logged_ai_sites ) ); } else { $site_result = $logged_ai_clean_site(); if ( is_wp_error( $site_result ) ) { return $site_result; } } // Network metadata is installation-owned, including networks with no current sites. $network_options = array( 'logged_ai_migration_network', 'logged_ai_network_alerts', 'logged_ai_network_alert_cleanup_pending', 'logged_ai_pro_setup', 'logged_ai_pro_cursor', 'logged_ai_pro_request_schema' ); if ( is_multisite() ) { $network_cursor = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate current network IDs, including networks without sites, in bounded keyset pages for explicit installation cleanup. $networks = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$wpdb->site} WHERE id>%d ORDER BY id LIMIT 100", $network_cursor ) ); /** * Validate network IDs before advancing the Free uninstall cursor from the untyped wpdb adapter. * * @var mixed $networks */ if ( '' !== $wpdb->last_error || ! is_array( $networks ) ) { return new WP_Error( 'uninstall_network', 'Network cleanup enumeration failed.' ); } foreach ( $networks as $network ) { $network_cursor = (int) $network; foreach ( $network_options as $option ) { delete_network_option( $network_cursor, $option ); } $marks = implode( ',', array_fill( 0, count( $network_options ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Native wpdb sitemeta supplies the table. Network cursor uses %d; marks is one generated %s per entry in the literal six-key network-option list, with separate bound values. Verify actual absence of the fixed owned metadata after native network-option deletion; cached absence is not completion proof. One network %d precedes six generated %s slots; arguments are the network cursor followed by the same six fixed owned metadata keys. $left = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->sitemeta} WHERE site_id=%d AND meta_key IN ({$marks})", $network_cursor, ...$network_options ) ); if ( '' !== $wpdb->last_error || null === $left || 0 !== (int) $left ) { return new WP_Error( 'uninstall_network', 'Network metadata cleanup could not be verified.' ); } } // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- This bounded collection changes during each page/cleanup step. Its current native array count is an O(1) continuation check and must not be hoisted outside the loop. } while ( 100 === count( $networks ) ); } else { foreach ( $network_options as $option ) { delete_network_option( 1, $option ); } $marks = implode( ',', array_fill( 0, count( $network_options ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Native wpdb options supplies the table; the same fixed six-key network-option list supplies separate values for generated %s placeholders. No request option names or SQL enter the query. Verify actual absence of owned network-style metadata in single-site options after native deletion; cached values are not physical cleanup evidence. Six generated %s slots receive exactly the same six fixed owned network-option keys. $left = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name IN ({$marks})", ...$network_options ) ); if ( '' !== $wpdb->last_error || null === $left || 0 !== (int) $left ) { return new WP_Error( 'uninstall_network', 'Installation metadata cleanup could not be verified.' ); } } foreach ( array( 'events', 'auth_windows', 'auth_target_windows', 'deliveries', 'alert_cooldowns', 'export_jobs', 'export_items', 'export_chunks', 'requests', 'request_limits', 'request_groups', 'response_state', 'response_policies', 'response_history', 'network_state', 'automatic_state', 'automatic_evidence' ) as $logged_ai_suffix ) { // Only hard-coded owned tables; no submitted table names or wildcard DROP. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DROP identifiers use only sixteen literal owned suffixes and escaped native prefix; no submitted table name or wildcard. Drop only the fixed owned table suffixes under explicit persisted deletion consent and the held lifecycle mutation lease; native option/cache APIs cannot remove plugin schemas. Explicit persisted uninstall consent and the held mutation lease authorize deletion of only fixed owned table names; native option APIs cannot remove the plugin schema. if ( false === $wpdb->query( 'DROP TABLE IF EXISTS `' . str_replace( '`', '``', $wpdb->base_prefix . 'logged_ai_' . $logged_ai_suffix ) . '`' ) ) { return new WP_Error( 'uninstall_storage', 'An owned data table could not be removed. The explicit uninstall choice remains available for retry.' ); } } if ( ! delete_network_option( is_multisite() ? get_main_network_id() : 1, 'logged_ai_delete_all_on_uninstall' ) ) { return new WP_Error( 'uninstall_choice', 'Owned data was cleaned, but the explicit uninstall choice could not be cleared.' ); } return true; } ); if ( is_wp_error( $logged_ai_uninstall_result ) ) { throw new RuntimeException( 'Logged.ai cleanup is incomplete. Retained state and the explicit uninstall choice may require retry.' ); } unset( $logged_ai_delete_choice, $logged_ai_uninstall_result );