Erase Personal Data * contain no readable identity, but are plugin-owned state and are removed * from user_request post meta when local-data deletion is requested. * * Runs with only core WordPress guaranteed to be loaded: WooCommerce and * Action Scheduler may be deactivated or already deleted by the time this * fires, so every non-core function is guarded by function_exists() and * every non-core table by an existence check. */ /** * Read one option without populating the option cache or unserializing data. * * The length query is deliberately separate from the value query. Privacy * state is fail-closed, so an oversized/corrupt row must never be handed to * maybe_unserialize() merely to decide whether uninstall is safe. */ function orderguard_uninstall_read_bounded_option( $wpdb, $option_name, $maximum_bytes, &$ok, &$found ) { $ok = false; $found = false; try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $length = $wpdb->get_var( $wpdb->prepare( "SELECT OCTET_LENGTH(option_value) FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- bounded raw read avoids unsafe privacy-state deserialization. } catch ( Throwable $error ) { return null; } if ( '' !== $wpdb->last_error ) { return null; } if ( null === $length ) { $ok = true; return null; } $found = true; if ( ! is_numeric( $length ) || (int) $length < 0 || (int) $length > (int) $maximum_bytes ) { return null; } try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s AND OCTET_LENGTH(option_value) <= %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name, (int) $maximum_bytes ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- value is loaded only after its database byte length was bounded. } catch ( Throwable $error ) { return null; } if ( '' !== $wpdb->last_error || ! is_string( $raw ) || strlen( $raw ) > (int) $maximum_bytes ) { return null; } $ok = true; return $raw; } /** Read only whether an exact option row exists; never load its value. */ function orderguard_uninstall_option_exists( $wpdb, $option_name, &$ok ) { $ok = false; try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $found = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- existence alone is the uninstall boundary. } catch ( Throwable $error ) { return false; } if ( '' !== $wpdb->last_error ) { return false; } $ok = true; return null !== $found; } /** Determine exact destructive consent without ever loading an oversized value. */ function orderguard_uninstall_option_is_exact_yes( $wpdb, $option_name, &$ok, &$found ) { $ok = false; $found = false; try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $length = $wpdb->get_var( $wpdb->prepare( "SELECT OCTET_LENGTH(option_value) FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact consent is decided by three bytes only. } catch ( Throwable $error ) { return false; } if ( '' !== $wpdb->last_error ) { return false; } if ( null === $length ) { $ok = true; return false; } $found = true; if ( ! is_numeric( $length ) || (int) $length < 0 ) { return false; } if ( 3 !== (int) $length ) { $ok = true; return false; } $raw_ok = false; $raw_found = false; $raw = orderguard_uninstall_read_bounded_option( $wpdb, $option_name, 3, $raw_ok, $raw_found ); if ( ! $raw_ok || ! $raw_found ) { return false; } $ok = true; return 'yes' === $raw; } /** Decode only canonical PHP arrays and never instantiate stored classes. */ function orderguard_uninstall_decode_privacy_array( $raw, &$ok ) { $ok = false; if ( ! is_string( $raw ) || ! preg_match( '/^a:[0-9]+:\{/', $raw ) ) { return array(); } try { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- raw bytes are size-bounded first; classes and excessive recursion are explicitly disabled. $decoded = @unserialize( $raw, array( 'allowed_classes' => false, 'max_depth' => 64, ) ); } catch ( Throwable $error ) { return array(); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- canonical round-trip rejects trailing or ambiguous bytes. if ( ! is_array( $decoded ) || serialize( $decoded ) !== $raw ) { return array(); } $ok = true; return $decoded; } /** Return true only when a destructive query ran and its zero-row postcondition holds. */ function orderguard_uninstall_delete_query_verified( $delete_query, $remaining_query ) { global $wpdb; try { $deleted = $wpdb->query( $delete_query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching -- caller supplies a prepared, narrowly scoped uninstall query; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; must read current database state (locks, claims, delete checks); caching would be wrong if ( false === $deleted || '' !== $wpdb->last_error ) { return false; } $remaining = $wpdb->get_var( $remaining_query ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching -- caller supplies the prepared zero-row verification query; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; must read current database state (locks, claims, delete checks); caching would be wrong } catch ( Throwable $error ) { return false; } return '' === $wpdb->last_error && is_numeric( $remaining ) && 0 === (int) $remaining; } /** Delete one exact option and prove that its database row is absent. */ function orderguard_uninstall_delete_option_verified( $option_name ) { global $wpdb; try { // False also means the row was already absent, so the database // postcondition below—not this ambiguous API result—is authoritative. $delete_result = delete_option( $option_name ); if ( '' !== $wpdb->last_error ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- verifies WordPress's option deletion before consent can be removed. } catch ( Throwable $error ) { return false; } return '' === $wpdb->last_error && is_numeric( $remaining ) && 0 === (int) $remaining; } /** Invoke cache invalidation without allowing a drop-in exception to bypass cleanup accounting. */ function orderguard_uninstall_cache_delete_safely( $key, $group ) { try { // A false cache result means either "not present" or "not deleted"; // database absence is verified separately wherever persistent state // exists, so only an exception is a distinguishable failure here. wp_cache_delete( $key, $group ); } catch ( Throwable $error ) { return false; } return true; } /** Check an optional table without mistaking a failed SHOW query for absence. */ function orderguard_uninstall_table_exists( $table_name, &$ok ) { global $wpdb; $ok = false; try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- optional local table existence check. } catch ( Throwable $error ) { return false; } if ( '' !== $wpdb->last_error ) { return false; } if ( null === $found ) { $ok = true; return false; } if ( ! is_string( $found ) || 0 !== strcasecmp( $found, $table_name ) ) { return false; } $ok = true; return true; } /** Exact owned-schema inventory. Never use a prefix wildcard for deletion. */ function orderguard_uninstall_owned_table_suffixes() { return array( 'control','operations','receipts','flags','block_revision','block_backup', 'privacy_cases','privacy_case_links','privacy_case_keys','privacy_namespaces', 'transport_operations','transport_subjects','transport_generations','transport_disposals', 'subject_authority_state','subject_authority','native_lifecycle_v1','historical_fences', 'native_erasure_bindings','native_erasure_attention','native_attention_authority','native_scope_candidates', 'native_scope_pages','native_order_fences','native_remote_operations_v4','native_remote_outcomes_v4', 'native_send_attempts_v4','native_receipt_acks_v1','native_core_journal_v1','native_scheduled_attempts_v1','native_retry_resumes_v1','native_clock_observations_v1', 'native_local_cleanup_v1','native_local_flag_manifests_v1','native_flag_projection_v1','native_block_touches_v1', 'native_takeover_progress_v1','native_legal_epoch_v1','native_legal_results_v1','native_legal_holds_v1','native_legal_releases_v1', 'native_restart_requests_v1','native_operation_archive_v1','native_operation_audit_v1','native_operation_attention_v1', ); } /** The original component-only prototype contained exactly these base tables. * Any later table is evidence that the lifecycle authority should exist. */ function orderguard_uninstall_base_table_suffixes() { return array( 'control','operations','receipts','flags','block_revision','block_backup', 'privacy_cases','privacy_case_links','privacy_case_keys','privacy_namespaces', 'transport_operations','transport_subjects','transport_generations','transport_disposals', 'subject_authority_state','subject_authority', ); } /** Authenticate the sole lifecycle row against the control secret. */ function orderguard_uninstall_native_lifecycle_is_valid() { global $wpdb; $lifecycle = $wpdb->prefix . 'og_owned_native_lifecycle_v1'; $control = $wpdb->prefix . 'og_owned_control'; try { $wpdb->last_error = ''; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $row = $wpdb->get_row( "SELECT l.install_id,l.generation,l.mode,l.state,l.body_hmac,c.secret FROM `{$lifecycle}` l INNER JOIN `{$control}` c ON c.singleton=1 AND c.install_id=l.install_id AND c.generation=l.generation WHERE l.singleton=1", ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $row ) ) { return false; } foreach ( array( 'install_id', 'generation', 'mode', 'state', 'body_hmac', 'secret' ) as $field ) { if ( ! isset( $row[ $field ] ) || ! is_string( $row[ $field ] ) ) { return false; } } if ( ! preg_match( '/^[a-f0-9]{32}$/D', $row['install_id'] ) || ! preg_match( '/^[a-f0-9]{32}$/D', $row['generation'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $row['body_hmac'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $row['secret'] ) || ! in_array( $row['mode'], array( 'fresh', 'upgrade' ), true ) || ! in_array( $row['state'], array( 'prepared', 'active' ), true ) ) { return false; } $body = "og-native-lifecycle-v1\n" . $row['install_id'] . "\n" . $row['generation'] . "\n" . $row['mode'] . "\n" . $row['state']; $key = hex2bin( $row['secret'] ); return false !== $key && hash_equals( hash_hmac( 'sha256', $body, $key ), $row['body_hmac'] ); } catch ( Throwable $error ) { return false; } } /** Unknown, partial or active native erasure state blocks uninstall. */ function orderguard_uninstall_native_has_pending_erasure() { global $wpdb; $present = array(); foreach ( orderguard_uninstall_owned_table_suffixes() as $suffix ) { $ok = false; $exists = orderguard_uninstall_table_exists( $wpdb->prefix . 'og_owned_' . $suffix, $ok ); if ( ! $ok ) { return true; } if ( $exists ) { $present[ $suffix ] = true; } } if ( empty( $present ) ) { return false; } // Only the exact 16-table component prototype is exempt. Any add-on table // without lifecycle authority is incomplete/unknown and must block. if ( ! isset( $present['native_lifecycle_v1'] ) ) { return (bool) array_diff( array_keys( $present ), orderguard_uninstall_base_table_suffixes() ); } if ( count( $present ) !== count( orderguard_uninstall_owned_table_suffixes() ) ) { return true; } if ( ! orderguard_uninstall_native_lifecycle_is_valid() ) { return true; } // Do not discard the only durable receipt while server acknowledgement is // pending or damaged. No transport or scheduling runs during uninstall. try { require_once __DIR__ . '/owned/wordpress-context.php'; $ack_context = ( new OG_Owned_WordPress_Context( $wpdb ) )->context(); if ( ! $ack_context['writer']->nativeReceiptAcksConfirmed( $ack_context ) ) { return true; } } catch ( Throwable $error ) { return true; } $bindings = $wpdb->prefix . 'og_owned_native_erasure_bindings'; $progress = $wpdb->prefix . 'og_owned_native_takeover_progress_v1'; $holds = $wpdb->prefix . 'og_owned_native_legal_holds_v1'; try { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $open = $wpdb->get_var( "SELECT COUNT(*) FROM `{$bindings}` b LEFT JOIN `{$progress}` p ON p.install_id=b.install_id AND p.namespace=b.namespace AND p.request_id=b.request_id AND p.stage='finished' WHERE p.request_id IS NULL" ); if ( '' !== $wpdb->last_error || ! is_numeric( $open ) || (int) $open > 0 ) { return true; } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $held = $wpdb->get_var( "SELECT COUNT(*) FROM `{$holds}`" ); return '' !== $wpdb->last_error || ! is_numeric( $held ) || (int) $held > 0; } catch ( Throwable $error ) { return true; } } /** Drop and then verify only the reviewed exact owned-schema names. */ function orderguard_uninstall_drop_owned_tables() { global $wpdb; $ok = true; foreach ( array_reverse( orderguard_uninstall_owned_table_suffixes() ) as $suffix ) { $table = $wpdb->prefix . 'og_owned_' . $suffix; try { $wpdb->last_error = ''; $dropped = $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- prefix plus exact reviewed suffix; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound if ( false === $dropped || '' !== $wpdb->last_error ) { $ok = false; } } catch ( Throwable $error ) { $ok = false; } $read_ok = false; if ( orderguard_uninstall_table_exists( $table, $read_ok ) || ! $read_ok ) { $ok = false; } } return $ok; } /** * Persist a canonical consent marker before a legacy-only fleet cleanup. * * Every later failure can therefore leave one exact current marker in place * for a safe retry even after migration-control options have been removed. */ function orderguard_uninstall_ensure_current_consent_marker() { global $wpdb; try { update_option( 'orderguard_delete_data_on_uninstall', 'yes', false ); if ( '' !== $wpdb->last_error ) { return false; } $ok = false; $found = false; $raw = orderguard_uninstall_read_bounded_option( $wpdb, 'orderguard_delete_data_on_uninstall', 16, $ok, $found ); } catch ( Throwable $error ) { return false; } return $ok && $found && 'yes' === $raw; } /** * Delete every piece of OrderGuard data stored on the CURRENT site, provided * this site's merchant opted in. Called once on single site, and once per * site (inside switch_to_blog) on multisite, because WordPress fires * uninstall.php only once for the whole network. */ function orderguard_uninstall_site_has_pending_erasure() { global $wpdb; if ( orderguard_uninstall_native_has_pending_erasure() ) { return true; } $fault_ok = false; $fault_found = orderguard_uninstall_option_exists( $wpdb, 'orderguard_gdpr_erasure_protocol_fault', $fault_ok ); if ( ! $fault_ok || $fault_found ) { return true; } // A signal may already have crossed the network boundary even when its // response was lost. Never discard the only exact replay/retraction state // while an operation is pending, ambiguous or needs attention. $signal_outbox = $wpdb->prefix . 'orderguard_signal_outbox'; $signal_table_ok = false; if ( orderguard_uninstall_table_exists( $signal_outbox, $signal_table_ok ) ) { try { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound; OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $open_signals = $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound "SELECT COUNT(*) FROM `{$signal_outbox}` WHERE state NOT IN ('committed','cancelled','refused')" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery -- exact plugin-owned table and bounded scalar guard. } catch ( Throwable $signal_read_error ) { return true; } if ( '' !== $wpdb->last_error || null === $open_signals || (int) $open_signals > 0 ) { return true; } } elseif ( ! $signal_table_ok ) { return true; } foreach ( array( 'orderguard_gdpr_erasure_pending', 'orderguard_exclusions' ) as $option_name ) { $raw_ok = false; $raw_found = false; $raw = orderguard_uninstall_read_bounded_option( $wpdb, $option_name, 2 * 1024 * 1024, $raw_ok, $raw_found ); if ( ! $raw_ok ) { return true; } if ( ! $raw_found ) { continue; } $decode_ok = false; $stored = orderguard_uninstall_decode_privacy_array( $raw, $decode_ok ); if ( ! $decode_ok ) { return true; // unreadable privacy state must fail closed } if ( 'orderguard_gdpr_erasure_pending' === $option_name ) { if ( ! empty( $stored ) ) { return true; } continue; } foreach ( $stored as $entry ) { if ( ! is_array( $entry ) ) { return true; } if ( ! empty( $entry['erase_pending'] ) && ! ( ! empty( $entry['erase_waiting_for_receipt_key'] ) && array_key_exists( 'erase_attempts', $entry ) && 0 === (int) ( $entry['erase_attempts'] ?? 0 ) ) ) { return true; } } } if ( function_exists( 'orderguard_compat_uninstall_has_pending_erasure' ) && orderguard_compat_uninstall_has_pending_erasure( $wpdb ) ) { return true; } return ! orderguard_uninstall_privacy_register_is_valid(); } /** Verify completed local suppression state without loading the plugin. */ function orderguard_uninstall_privacy_register_is_valid() { global $wpdb; $values = array(); $found = array(); foreach ( array( 'site' => 'orderguard_gdpr_erasure_site_id', 'canary' => 'orderguard_gdpr_erasure_integrity', 'register' => 'orderguard_gdpr_erasure_suppressions', ) as $key => $option_name ) { $raw_ok = false; $raw_found = false; $raw = orderguard_uninstall_read_bounded_option( $wpdb, $option_name, 'register' === $key ? 4 * 1024 * 1024 : 8192, $raw_ok, $raw_found ); if ( ! $raw_ok ) { return false; } $found[ $key ] = $raw_found; if ( ! $raw_found ) { $values[ $key ] = null; } elseif ( 'site' === $key ) { $values[ $key ] = $raw; } else { $decode_ok = false; $values[ $key ] = orderguard_uninstall_decode_privacy_array( $raw, $decode_ok ); if ( ! $decode_ok ) { return false; } } } $present = (int) $found['site'] + (int) $found['canary'] + (int) $found['register']; if ( 0 === $present ) { return true; } if ( 3 !== $present || ! is_string( $values['site'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $values['site'] ) ) { return false; } $canary = $values['canary']; if ( ! is_array( $canary ) || array( 'schema_version', 'generation', 'digest' ) !== array_keys( $canary ) || 2 !== $canary['schema_version'] || ! is_string( $canary['generation'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $canary['generation'] ) || ! is_string( $canary['digest'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $canary['digest'] ) ) { return false; } $secret = defined( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' ) && is_string( constant( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' ) ) && strlen( constant( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' ) ) >= 32 ? constant( 'ORDERGUARD_GDPR_SUPPRESSION_SECRET' ) : wp_salt( 'orderguard_gdpr_suppression' ); $expected_canary = hash_hmac( 'sha256', 'orderguard-gdpr-register-v2|' . $values['site'] . '|' . $canary['generation'], $secret ); if ( ! hash_equals( $expected_canary, $canary['digest'] ) ) { return false; } $register = $values['register']; if ( ! is_array( $register ) || array( 'schema_version', 'generation', 'entries', 'digest' ) !== array_keys( $register ) || 3 !== $register['schema_version'] || ! is_string( $register['generation'] ) || ! hash_equals( $canary['generation'], $register['generation'] ) || ! is_array( $register['entries'] ) || ! is_string( $register['digest'] ) || ! preg_match( '/^[a-f0-9]{64}$/', $register['digest'] ) ) { return false; } $entries = array(); foreach ( $register['entries'] as $storage_key => $boundary ) { if ( ! is_string( $storage_key ) || ! preg_match( '/^[a-f0-9]{64}$/', $storage_key ) || ! is_array( $boundary ) || array( 'cutoff', 'max_order_id' ) !== array_keys( $boundary ) || ! is_int( $boundary['cutoff'] ) || ! is_int( $boundary['max_order_id'] ) || $boundary['cutoff'] <= 0 || $boundary['max_order_id'] < -1 ) { return false; } $entries[ $storage_key ] = $boundary; } ksort( $entries, SORT_STRING ); $encoded = wp_json_encode( array( 'schema_version' => 3, 'namespace' => $values['site'], 'generation' => $register['generation'], 'entries' => $entries, ), JSON_UNESCAPED_SLASHES ); if ( ! is_string( $encoded ) || '' === $encoded ) { return false; } return hash_equals( hash_hmac( 'sha256', $encoded, $secret ), $register['digest'] ); } /** * Freeze the exact multisite population before the all-or-nothing preflight. * * OFFSET pagination over a changing blogs table can skip a surviving site * when another site is deleted between pages. Keyset pages build one immutable * ID snapshot, which is then reused for both preflight and deletion. */ function orderguard_uninstall_network_site_ids( &$ok = null ) { global $wpdb; $ok = false; if ( empty( $wpdb->blogs ) ) { return array(); } $site_ids = array(); $after_id = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $page = $wpdb->get_col( $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id > %d ORDER BY blog_id ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core network table, numeric keyset bounds. $after_id, 200 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- deletion-safe network-site snapshot for uninstall. if ( '' !== $wpdb->last_error || ! is_array( $page ) ) { return array(); } $page = array_values( array_unique( array_filter( array_map( 'absint', $page ) ) ) ); sort( $page, SORT_NUMERIC ); foreach ( $page as $site_id ) { if ( $site_id <= $after_id ) { return array(); } $site_ids[] = $site_id; $after_id = $site_id; } } while ( 200 === count( $page ) ); $ok = true; return $site_ids; } /** * Share one database mutex with erasure acceptance in the loaded plugin. * * WordPress deactivates a plugin before uninstall.php runs. An admin request * that loaded the old request can therefore finish concurrently; the mutex * makes that request either publish first (so this uninstall refuses) or see * the deactivated state after this uninstaller releases the lock. */ function orderguard_uninstall_gdpr_lifecycle_lock_name() { global $wpdb; return 'orderguard_gdpr_' . substr( hash( 'sha256', (string) $wpdb->dbhost . '|' . (string) $wpdb->dbname ), 0, 40 ); } function orderguard_uninstall_acquire_gdpr_lifecycle_lock() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $claimed = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', orderguard_uninstall_gdpr_lifecycle_lock_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- serialises uninstall against erasure acceptance. return '1' === (string) $claimed && '' === $wpdb->last_error; } function orderguard_uninstall_release_gdpr_lifecycle_lock() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', orderguard_uninstall_gdpr_lifecycle_lock_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- releases the matching lifecycle mutex. } function orderguard_uninstall_delete_site_data( &$orderguard_delete_requested = null ) { global $wpdb; $orderguard_delete_requested = false; $orderguard_cleanup_ok = true; // Consent is destructive and must not depend on an object-cache snapshot. // New wins whenever it exists. Legacy is eligible only when the database // directly confirms that this site was never migrated. $orderguard_new_consent_ok = false; $orderguard_new_consent_found = false; $orderguard_new_consent = orderguard_uninstall_option_is_exact_yes( $wpdb, 'orderguard_delete_data_on_uninstall', $orderguard_new_consent_ok, $orderguard_new_consent_found ) ? 'yes' : 'no'; if ( ! $orderguard_new_consent_ok ) { return false; } $orderguard_migration_stamp_ok = false; $orderguard_migration_stamp_found = orderguard_uninstall_option_exists( $wpdb, 'orderguard_data_migration_version', $orderguard_migration_stamp_ok ); if ( ! $orderguard_migration_stamp_ok ) { return false; } if ( $orderguard_new_consent_found ) { $orderguard_consent = $orderguard_new_consent; } elseif ( ! $orderguard_migration_stamp_found && function_exists( 'orderguard_compat_uninstall_consent' ) ) { $orderguard_consent_ok = false; $orderguard_consent = orderguard_compat_uninstall_consent( $wpdb, $orderguard_consent_ok ); if ( ! $orderguard_consent_ok ) { return false; } } else { $orderguard_consent = 'no'; } // Anything other than an exact current 'yes' retains every row. if ( 'yes' !== $orderguard_consent ) { return true; } $orderguard_delete_requested = true; // A legacy-only fleet consent is promoted before any destructive work. // This marker is intentionally kept until the driver proves every site // cleanup complete, so any partial failure remains safely retryable. if ( ! $orderguard_new_consent_found && ! orderguard_uninstall_ensure_current_consent_marker() ) { return false; } /* * ---------------------------------------------------------------------- * 1. Scheduled background work (Action Scheduler + WP-Cron fallback). * ---------------------------------------------------------------------- * The plugin schedules everything into Action Scheduler group * 'orderguard'. Action Scheduler ships with WooCommerce, which may be * deactivated when uninstall runs, hence the function_exists() guards. * If it is unavailable, any leftover pending actions fail harmlessly * once their hooks no longer have callbacks, and Action Scheduler's own * retention cleanup prunes them. */ $orderguard_as_hooks = array( 'orderguard_courier_sync', 'orderguard_courier_sync_batch', 'orderguard_courier_backfill_batch', 'orderguard_courier_map_apply', 'orderguard_import_batch', 'orderguard_reconciliation_sweep', 'orderguard_reconcile_single', 'orderguard_retry_event', 'orderguard_gdpr_deferred_order', 'orderguard_process_gdpr_erasure', 'orderguard_exclusion_erase_retry', 'orderguard_native_receipt_ack_one', 'orderguard_native_receipt_ack_tick', 'orderguard_native_restart_tick', ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { $orderguard_as_hooks = array_merge( $orderguard_as_hooks, orderguard_compat_uninstall_inventory( 'as_hooks' ) ); } if ( function_exists( 'as_unschedule_all_actions' ) ) { try { foreach ( $orderguard_as_hooks as $orderguard_as_hook ) { // Null args match every argument shape in the plugin's group. as_unschedule_all_actions( $orderguard_as_hook, null, 'orderguard' ); } // Belt and braces: cancel anything else left in the plugin's group // (empty hook + group hits the cancel-whole-group path). as_unschedule_all_actions( '', array(), 'orderguard' ); // Action Scheduler's bulk API returns void and internally catches // cancellation exceptions. Its read API is therefore the only // reliable postcondition for the explicit OrderGuard inventory. if ( function_exists( 'as_has_scheduled_action' ) ) { foreach ( $orderguard_as_hooks as $orderguard_as_hook ) { if ( as_has_scheduled_action( $orderguard_as_hook, null, 'orderguard' ) ) { $orderguard_cleanup_ok = false; } } } } catch ( Throwable $orderguard_scheduler_error ) { $orderguard_cleanup_ok = false; } } // courier-sync.php falls back to wp_schedule_single_event() for // orderguard_courier_map_apply when Action Scheduler is unavailable, so clear // the WP-Cron side too. wp_clear_scheduled_hook() only removes events // whose args match exactly (and the fallback schedules WITH args), so // wp_unschedule_hook() -- which clears every event for the hook // regardless of args -- is the call that actually catches them; both // run here for completeness. $orderguard_wp_cron_hooks = array( 'orderguard_courier_map_apply', 'orderguard_gdpr_deferred_order', 'orderguard_process_gdpr_erasure', 'orderguard_exclusion_erase_retry', // Native receipt ACK and restart polls are WP-Cron events. 'orderguard_native_receipt_ack_one', 'orderguard_native_receipt_ack_tick', 'orderguard_native_restart_tick', ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { foreach ( orderguard_compat_uninstall_inventory( 'as_hooks' ) as $orderguard_legacy_hook ) { if ( false !== strpos( $orderguard_legacy_hook, 'courier_map_apply' ) ) { $orderguard_wp_cron_hooks[] = $orderguard_legacy_hook; } } } foreach ( array_values( array_unique( $orderguard_wp_cron_hooks ) ) as $orderguard_wp_cron_hook ) { try { // Keep the empty-argument cleanup for old installs, then use the // all-argument API as the load-bearing operation. $orderguard_cron_result = wp_clear_scheduled_hook( $orderguard_wp_cron_hook, array(), true ); if ( false === $orderguard_cron_result || is_wp_error( $orderguard_cron_result ) ) { $orderguard_cleanup_ok = false; } $orderguard_cron_result = wp_unschedule_hook( $orderguard_wp_cron_hook, true ); if ( false === $orderguard_cron_result || is_wp_error( $orderguard_cron_result ) ) { $orderguard_cleanup_ok = false; } } catch ( Throwable $orderguard_cron_error ) { $orderguard_cleanup_ok = false; } } /* * ---------------------------------------------------------------------- * 2. Transients. * ---------------------------------------------------------------------- * Fixed-name transients go through the API first so an external object * cache is cleared too. Dynamic families cannot be enumerated because * their suffixes contain user IDs, order IDs or identity fingerprints; * each family is therefore swept by its own explicit database prefix. * A single generic prefix is deliberately forbidden: the short legacy * namespace is not exclusive to OrderGuard. */ $orderguard_fixed_transients = array( 'orderguard_admin_notice', 'orderguard_exclusion_retry_lock', 'orderguard_paddle_summary', 'orderguard_paddle_summary_bypass', 'orderguard_plan_cache', // The review count is trivial; the history result is the one that // matters -- 12 hours, and on a site with an external object cache the // SQL sweep below cannot reach it, so it must go through the API here. 'orderguard_review_count', 'orderguard_hist_clients', ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { $orderguard_fixed_transients = array_merge( $orderguard_fixed_transients, orderguard_compat_uninstall_inventory( 'fixed_transients' ) ); } foreach ( $orderguard_fixed_transients as $orderguard_transient ) { try { // The API clears an external object cache when one is active. // Its false return is ambiguous (missing versus failure), so the // database rows are independently deleted and verified below. $orderguard_transient_delete_result = delete_transient( $orderguard_transient ); if ( ! orderguard_uninstall_cache_delete_safely( $orderguard_transient, 'transient' ) ) { $orderguard_cleanup_ok = false; } if ( '' !== $wpdb->last_error ) { $orderguard_cleanup_ok = false; } } catch ( Throwable $orderguard_transient_error ) { $orderguard_cleanup_ok = false; } foreach ( array( '_transient_' . $orderguard_transient, '_transient_timeout_' . $orderguard_transient ) as $orderguard_transient_option ) { if ( ! orderguard_uninstall_delete_option_verified( $orderguard_transient_option ) ) { $orderguard_cleanup_ok = false; } } } // These eight names are the complete dynamic-family inventory pinned by // tests/fixtures/w6_inventory.json. Four are irreducibly keyed by hashes // or order IDs (netsig, cod, priorc and ins_blk); the remaining four use a // user ID. Seven expire within ten minutes. The 24-hour outlier, ins_blk, // stores only the integer 1 as a checkout dedupe marker; it contains no // identity, score or count. Thus any copy held only by an external object // cache is both bounded and, for the longest-lived family, non-personal. $orderguard_transient_prefixes = array( 'orderguard_ask_nc_', 'orderguard_cod_', 'orderguard_data_result_', 'orderguard_exclusion_notice_', 'orderguard_flag_notice_', 'orderguard_ins_blk_', 'orderguard_netsig_', 'orderguard_priorc_', ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { $orderguard_transient_prefixes = array_merge( $orderguard_transient_prefixes, orderguard_compat_uninstall_inventory( 'transient_prefixes' ) ); } $orderguard_transient_patterns = array(); foreach ( $orderguard_transient_prefixes as $orderguard_transient_prefix ) { $orderguard_transient_patterns[] = $wpdb->esc_like( '_transient_' . $orderguard_transient_prefix ) . '%'; $orderguard_transient_patterns[] = $wpdb->esc_like( '_transient_timeout_' . $orderguard_transient_prefix ) . '%'; } foreach ( $orderguard_transient_patterns as $orderguard_pattern ) { if ( ! orderguard_uninstall_delete_query_verified( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $orderguard_pattern ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core table name from $wpdb, pattern bound. $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s", $orderguard_pattern ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- matching postcondition for the same bounded family. ) ) { $orderguard_cleanup_ok = false; } } // OrderGuard never sets site transients. Their single-site rows would use // wp_options and their multisite rows live in wp_sitemeta, so sweeping a // site-transient prefix here could only remove another plugin's data. /* * ---------------------------------------------------------------------- * 3. Options. * ---------------------------------------------------------------------- * Explicit, reviewable list of Free options other than the consent * toggle, which is deliberately deleted last. Pro-owned settings are not Free's data and * are deliberately absent; the separately distributed add-on owns their * uninstall lifecycle. */ $orderguard_free_options = array( 'orderguard_activated_at', 'orderguard_api_key', 'orderguard_challenge_token', 'orderguard_cod_gating', 'orderguard_courier_backfill', 'orderguard_courier_backfill_ids', 'orderguard_courier_daily_history', 'orderguard_courier_health', 'orderguard_courier_known_statuses', 'orderguard_courier_status_map', 'orderguard_courier_sync_cursor', 'orderguard_courier_sync_enabled', 'orderguard_courier_sync_sched_ver', 'orderguard_courier_synced_today', 'orderguard_courier_synced_total', 'orderguard_exclusion_erase_recovery_cursor', 'orderguard_native_ack_cursor', 'orderguard_native_restart_cursor', 'orderguard_exclusion_installation_id', 'orderguard_exclusions', 'orderguard_flag_enabled', 'orderguard_flag_notcollected_threshold', 'orderguard_flag_returned_threshold', 'orderguard_flags_db_version', 'orderguard_version_history', 'orderguard_gdpr_erasure_cache_epoch', 'orderguard_gdpr_erasure_integrity', 'orderguard_gdpr_erasure_pending', 'orderguard_gdpr_erasure_protocol_fault', 'orderguard_gdpr_erasure_site_id', 'orderguard_gdpr_erasure_suppressions', 'orderguard_import_state', 'orderguard_import_summary', 'orderguard_insights_data', 'orderguard_manual_flag_enabled', 'orderguard_network_signal_enabled', 'orderguard_network_signal_optin_at', 'orderguard_owned_ever_connected', 'orderguard_owned_cutover_audit', 'orderguard_signal_installation_id', 'orderguard_signal_outbox_db_version', 'orderguard_signal_reconcile_cursor', 'orderguard_sweep_sched_ver', // TASK 50. orderguard_history_dismissed holds CUSTOMER IDENTITY HASHES (the // clients the merchant marked "Looks fine"), so leaving it behind after an // opted-in delete would leave pseudonymous PII in wp_options for ever. 'orderguard_history_dismissed', 'orderguard_history_notice_dismissed', // 0.3.1: the merchant's "I have added it" record for the store privacy-notice text. 'orderguard_store_notice_confirmed', ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { $orderguard_free_options = array_merge( $orderguard_free_options, orderguard_compat_uninstall_inventory( 'options' ) ); } foreach ( $orderguard_free_options as $orderguard_option ) { if ( ! orderguard_uninstall_delete_option_verified( $orderguard_option ) ) { $orderguard_cleanup_ok = false; } } // Migration controls and repair diagnostics are new-only. The three // control rows use direct SQL here as they do at runtime. $orderguard_named_options = array( 'orderguard_data_migration_version', 'orderguard_data_migration_provenance', 'orderguard_data_migration_lock', 'orderguard_scheduler_cleanup_seen', 'orderguard_import_recovery_seen_at', 'orderguard_order_meta_migration_state', 'orderguard_order_meta_migration_ids', 'orderguard_order_meta_migration_version', 'orderguard_compat_started_at', 'orderguard_compat_config_verified', 'orderguard_compat_preserved_review_digest', 'orderguard_compat_cleanup_authorized', 'orderguard_compat_cleanup_version', ); foreach ( $orderguard_named_options as $orderguard_option ) { if ( ! orderguard_uninstall_delete_option_verified( $orderguard_option ) ) { $orderguard_cleanup_ok = false; } if ( ! orderguard_uninstall_cache_delete_safely( $orderguard_option, 'options' ) ) { $orderguard_cleanup_ok = false; } } if ( ! orderguard_uninstall_cache_delete_safely( 'notoptions', 'options' ) || ! orderguard_uninstall_cache_delete_safely( 'alloptions', 'options' ) ) { $orderguard_cleanup_ok = false; } /* * There is deliberately NO wildcard "og\_%" option sweep here, and it * should not be added. The legacy namespace is short and generic; Open * Graph tooling in particular uses it. Deleting another plugin's setting * is worse than retaining one unknown row. Only the explicit Free and * migration-control inventories above may remove options. */ /* * ---------------------------------------------------------------------- * 4. Order meta: legacy postmeta AND the HPOS orders-meta table. * ---------------------------------------------------------------------- * WooCommerce may keep order meta in wp_postmeta (legacy), in * wp_wc_orders_meta (HPOS), or in both (compatibility mode), so the 33 * explicit OrderGuard keys are deleted from both tables. Raw SQL is used * because the WooCommerce CRUD layer may not be loaded at uninstall time. * Each table is checked for existence before it is queried. */ $orderguard_order_meta_keys = array( '_orderguard_checked_at', '_orderguard_checkout_rejected', '_orderguard_collection_risk', '_orderguard_confidence', '_orderguard_counts_withheld', '_orderguard_gdpr_erased_at', '_orderguard_courier_first_seen', '_orderguard_courier_outcome', '_orderguard_courier_provenance', '_orderguard_insights_checked', '_orderguard_insights_flagged', '_orderguard_insights_hidden', '_orderguard_kept_count', '_orderguard_months_active', '_orderguard_not_collected', '_orderguard_not_collected_count', '_orderguard_order_count', '_orderguard_own_kept_count', '_orderguard_own_months_active', '_orderguard_own_not_collected_count', '_orderguard_own_note_added', '_orderguard_own_order_count', '_orderguard_own_returned_count', '_orderguard_return_risk', '_orderguard_returned_count', '_orderguard_repair_candidate', '_orderguard_reviewed', '_orderguard_seen_count', '_orderguard_snapshot_locked', '_orderguard_store_order_count', '_orderguard_sweep_fact_hash', '_orderguard_sweep_fact_sent_at', '_orderguard_unresolved_count', ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { $orderguard_order_meta_keys = array_merge( $orderguard_order_meta_keys, orderguard_compat_uninstall_inventory( 'order_meta' ) ); } $orderguard_meta_placeholders = implode( ', ', array_fill( 0, count( $orderguard_order_meta_keys ), '%s' ) ); // Legacy storage stays restricted to WooCommerce order post types. The // join is kept as a safety boundary even though the keys are explicit; // shop_order_placehold is the HPOS compatibility-mode placeholder type. $orderguard_postmeta_table = $wpdb->postmeta; $orderguard_table_ok = false; $orderguard_found = orderguard_uninstall_table_exists( $orderguard_postmeta_table, $orderguard_table_ok ); if ( ! $orderguard_table_ok ) { $orderguard_cleanup_ok = false; } elseif ( $orderguard_found && ! orderguard_uninstall_delete_query_verified( $wpdb->prepare( "DELETE pm FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key IN ( {$orderguard_meta_placeholders} ) AND p.post_type IN ( 'shop_order', 'shop_order_refund', 'shop_order_placehold' )", $orderguard_order_meta_keys ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- core tables and literal post types; explicit keys are bound; placeholders are generated to match the bound array, which the checker cannot count $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key IN ( {$orderguard_meta_placeholders} ) AND p.post_type IN ( 'shop_order', 'shop_order_refund', 'shop_order_placehold' )", $orderguard_order_meta_keys ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- exact postcondition for the matching scoped delete; placeholders are generated to match the bound array, which the checker cannot count ) ) { $orderguard_cleanup_ok = false; } // HPOS storage contains order meta only, but it uses the same explicit // inventory so no other plugin's order-meta schema is in scope. $orderguard_hpos_table = $wpdb->prefix . 'wc_orders_meta'; $orderguard_table_ok = false; $orderguard_found = orderguard_uninstall_table_exists( $orderguard_hpos_table, $orderguard_table_ok ); if ( ! $orderguard_table_ok ) { $orderguard_cleanup_ok = false; } elseif ( $orderguard_found && ! orderguard_uninstall_delete_query_verified( $wpdb->prepare( "DELETE FROM {$orderguard_hpos_table} WHERE meta_key IN ( {$orderguard_meta_placeholders} )", $orderguard_order_meta_keys ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- verified table name and explicit bound keys; placeholders are generated to match the bound array, which the checker cannot count $wpdb->prepare( "SELECT COUNT(*) FROM {$orderguard_hpos_table} WHERE meta_key IN ( {$orderguard_meta_placeholders} )", $orderguard_order_meta_keys ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- exact postcondition for the matching HPOS delete; placeholders are generated to match the bound array, which the checker cannot count ) ) { $orderguard_cleanup_ok = false; } // Drop the reviewed owned schema by exact names, in reverse dependency // order. The pending-state preflight above has already succeeded. if ( ! orderguard_uninstall_drop_owned_tables() ) { $orderguard_cleanup_ok = false; } // The signal outbox stores exact requests and the head table stores its // monotonic source state. With no open operation (checked before deletion), // both are safe to drop as part of the merchant's opted-in local cleanup. foreach ( array( 'orderguard_signal_outbox', 'orderguard_signal_heads' ) as $orderguard_signal_suffix ) { $orderguard_signal_table = $wpdb->prefix . $orderguard_signal_suffix; try { $orderguard_drop_result = $wpdb->query( "DROP TABLE IF EXISTS `{$orderguard_signal_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- prefix plus fixed plugin-owned suffix; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound if ( false === $orderguard_drop_result || '' !== $wpdb->last_error ) { $orderguard_cleanup_ok = false; } } catch ( Throwable $orderguard_drop_error ) { $orderguard_cleanup_ok = false; } $orderguard_table_ok = false; if ( orderguard_uninstall_table_exists( $orderguard_signal_table, $orderguard_table_ok ) || ! $orderguard_table_ok ) { $orderguard_cleanup_ok = false; } } // The native WordPress privacy bridge keeps one signed, identity-free // completion receipt on its user_request post. It is not WooCommerce // order meta, so remove and verify it through its own post-type boundary. $orderguard_native_receipt_key = '_orderguard_gdpr_erasure_receipt'; if ( ! orderguard_uninstall_delete_query_verified( $wpdb->prepare( "DELETE pm FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = 'user_request'", $orderguard_native_receipt_key ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core tables, fixed post type and one bound plugin-owned key. $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = 'user_request'", $orderguard_native_receipt_key ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- exact postcondition for the same receipt-meta delete. ) ) { $orderguard_cleanup_ok = false; } // Private order notes are part of WooCommerce's merchant audit trail and // remain with the order. This uninstall never sweeps wp_comments. /* * ---------------------------------------------------------------------- * 5. The custom flags table. * ---------------------------------------------------------------------- * {$wpdb->prefix}orderguard_flags stores raw name, email and phone * columns alongside the hashes, so dropping it is the personal-data * erasure that matters most in this file. */ $orderguard_flags_table = $wpdb->prefix . 'orderguard_flags'; try { $orderguard_drop_result = $wpdb->query( "DROP TABLE IF EXISTS `{$orderguard_flags_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name is $wpdb->prefix plus a literal; DDL cannot be prepared; only table names ($wpdb prefix plus a fixed suffix, or core tables) and generated placeholder lists are interpolated; every value is bound if ( false === $orderguard_drop_result || '' !== $wpdb->last_error ) { $orderguard_cleanup_ok = false; } } catch ( Throwable $orderguard_drop_error ) { $orderguard_cleanup_ok = false; } $orderguard_table_ok = false; if ( orderguard_uninstall_table_exists( $orderguard_flags_table, $orderguard_table_ok ) || ! $orderguard_table_ok ) { $orderguard_cleanup_ok = false; } /* * ---------------------------------------------------------------------- * 6. User meta. * ---------------------------------------------------------------------- * orderguard_admin_lang is each admin's EN/BG interface choice. billing_phone * and every other WooCommerce user meta key are deliberately untouched: * they are not ours. Note that the usermeta table is shared network-wide * on multisite, so on a network this runs once per consenting site * against the same table (idempotent; see NOTES.md). */ $orderguard_user_meta_keys = array( 'orderguard_admin_lang', 'orderguard_pro_install_notice_dismissed', 'orderguard_store_notice_dismissed' ); if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { $orderguard_user_meta_keys = array_merge( $orderguard_user_meta_keys, orderguard_compat_uninstall_inventory( 'user_meta' ) ); } foreach ( $orderguard_user_meta_keys as $orderguard_user_meta_key ) { try { // False also means there was no matching row; the direct count is // the load-bearing deletion result. $orderguard_user_meta_delete_result = delete_metadata( 'user', 0, $orderguard_user_meta_key, '', true ); if ( '' !== $wpdb->last_error ) { $orderguard_cleanup_ok = false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $orderguard_remaining_user_meta = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core usermeta table and explicit plugin-owned key. $orderguard_user_meta_key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- verifies the network-wide user-meta delete. if ( '' !== $wpdb->last_error || ! is_numeric( $orderguard_remaining_user_meta ) || 0 !== (int) $orderguard_remaining_user_meta ) { $orderguard_cleanup_ok = false; } } catch ( Throwable $orderguard_user_meta_error ) { $orderguard_cleanup_ok = false; } } return $orderguard_cleanup_ok; } /** Delete consent only after the driver has verified every requested cleanup. */ function orderguard_uninstall_delete_site_consent() { $orderguard_consent_ok = true; // Legacy first and canonical current marker last. If a legacy delete // fails, the current marker is deliberately left untouched for retry. if ( function_exists( 'orderguard_compat_uninstall_inventory' ) ) { foreach ( orderguard_compat_uninstall_inventory( 'consent' ) as $orderguard_consent_option ) { if ( ! orderguard_uninstall_delete_option_verified( $orderguard_consent_option ) ) { $orderguard_consent_ok = false; } } } if ( ! $orderguard_consent_ok ) { return false; } if ( orderguard_uninstall_delete_option_verified( 'orderguard_delete_data_on_uninstall' ) ) { return true; } // A failed postcondition can follow an uncertain delete result. Restore // the retry authority best-effort rather than knowingly strand cleanup. orderguard_uninstall_ensure_current_consent_marker(); return false; } /* * --------------------------------------------------------------------------- * Driver. uninstall.php fires ONCE for the whole network, not per site, so * on multisite every site is visited and cleaned only where that site's own * admin opted in. A keyset-paginated site-ID snapshot is frozen before the * preflight and reused for deletion, so a concurrently removed site cannot * shift a surviving site past an OFFSET boundary. * --------------------------------------------------------------------------- */ if ( ! orderguard_uninstall_acquire_gdpr_lifecycle_lock() ) { wp_die( 'OrderGuard could not secure customer-erasure state for uninstall. No local data was deleted. Reactivate OrderGuard, then try again.' ); } if ( is_multisite() ) { // Preflight the whole network before deleting anything. Otherwise one // later subsite with a pending remote erasure would leave earlier sites // already destroyed when uninstall aborts. $orderguard_snapshot_ok = false; $orderguard_uninstall_site_ids = orderguard_uninstall_network_site_ids( $orderguard_snapshot_ok ); if ( ! $orderguard_snapshot_ok ) { orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard could not read the complete network-site list. No local data was deleted. Reactivate OrderGuard, then try again.' ); } $orderguard_pending_sites = 0; foreach ( $orderguard_uninstall_site_ids as $orderguard_uninstall_site_id ) { switch_to_blog( (int) $orderguard_uninstall_site_id ); if ( orderguard_uninstall_site_has_pending_erasure() ) { ++$orderguard_pending_sites; } restore_current_blog(); } if ( $orderguard_pending_sites > 0 ) { orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard cannot be uninstalled while customer data removal is still pending. Reactivate OrderGuard, let the requests finish, then try again.' ); } $orderguard_consenting_site_ids = array(); foreach ( $orderguard_uninstall_site_ids as $orderguard_uninstall_site_id ) { switch_to_blog( (int) $orderguard_uninstall_site_id ); $orderguard_delete_requested = false; $orderguard_site_deleted = orderguard_uninstall_delete_site_data( $orderguard_delete_requested ); if ( $orderguard_delete_requested ) { $orderguard_consenting_site_ids[] = (int) $orderguard_uninstall_site_id; } restore_current_blog(); if ( ! $orderguard_site_deleted ) { orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard could not verify complete local data deletion. The delete-data consent was retained for a safe retry. Reactivate OrderGuard, then try again.' ); } } // Delay every site's consent deletion until all requested site cleanups // have succeeded. A failure on a later subsite can never strand an // earlier partial cleanup without its retry authority. $orderguard_consent_deleted_site_ids = array(); foreach ( $orderguard_consenting_site_ids as $orderguard_uninstall_site_id ) { switch_to_blog( (int) $orderguard_uninstall_site_id ); $orderguard_consent_deleted = orderguard_uninstall_delete_site_consent(); restore_current_blog(); if ( ! $orderguard_consent_deleted ) { // A later site's consent-delete failure must not leave earlier // sites without retry authority. Re-arm every already-verified // site best-effort before reporting the network failure. foreach ( $orderguard_consent_deleted_site_ids as $orderguard_deleted_consent_site_id ) { switch_to_blog( (int) $orderguard_deleted_consent_site_id ); orderguard_uninstall_ensure_current_consent_marker(); restore_current_blog(); } orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard removed local data but could not verify removal of the delete-data consent. Reactivate OrderGuard, then retry uninstall.' ); } $orderguard_consent_deleted_site_ids[] = (int) $orderguard_uninstall_site_id; } } else { if ( orderguard_uninstall_site_has_pending_erasure() ) { orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard cannot be uninstalled while customer data removal is still pending. Reactivate OrderGuard, let the requests finish, then try again.' ); } $orderguard_delete_requested = false; if ( ! orderguard_uninstall_delete_site_data( $orderguard_delete_requested ) ) { orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard could not verify complete local data deletion. The delete-data consent was retained for a safe retry. Reactivate OrderGuard, then try again.' ); } if ( $orderguard_delete_requested && ! orderguard_uninstall_delete_site_consent() ) { orderguard_uninstall_release_gdpr_lifecycle_lock(); wp_die( 'OrderGuard removed local data but could not verify removal of the delete-data consent. Reactivate OrderGuard, then retry uninstall.' ); } } orderguard_uninstall_release_gdpr_lifecycle_lock();