$context Optional context. * @return array */ public function normalize( string|Throwable|WP_Error $error, array $context = [] ): array { $message = $this->extract_message( $error ); $lower = strtolower( $message ); $stage = isset( $context['stage'] ) ? sanitize_key( (string) $context['stage'] ) : 'general'; $debug = $this->extract_debug_details( $error ); // Classify on the raw message so phrases match before redaction rewrites them. $safe = self::redact_message( $message ); $payload = [ 'code' => 'unknown_error', 'severity' => 'error', 'stage' => '' !== $stage ? $stage : 'general', 'message' => $safe, 'technical_message' => $safe, 'user_message' => $safe, 'suggested_actions' => [ 'retry', 'report' ], 'timestamp' => gmdate( 'c' ), 'debug' => $debug, 'context' => $this->sanitize_context( $context ), ]; // First match wins, so specific phrases are excluded before broad ones: a lock // wait is not a slow remote, and "unexpected token" is an HTML error page, not a bad credential. $filesystem = str_contains( $lower, 'permission denied' ) || str_contains( $lower, 'failed to open stream' ) || str_contains( $lower, 'no space left' ); if ( ! str_contains( $lower, 'lock wait timeout' ) && ( str_contains( $lower, 'curl error 28' ) || str_contains( $lower, 'timed out' ) || str_contains( $lower, 'timeout' ) ) ) { $payload['code'] = 'network_timeout'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'The remote site took too long to respond. You can safely resume or retry from the last checkpoint.', 'shiftora' ); $payload['suggested_actions'] = [ 'resume', 'retry', 'report' ]; } elseif ( str_contains( $lower, 'backup failed' ) || str_contains( $lower, 'cannot open backup file' ) || str_contains( $lower, 'failed to get create table' ) || str_contains( $lower, 'no tables provided for backup' ) || str_contains( $lower, 'backup directory' ) ) { $payload['code'] = 'local_backup_unavailable'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'Shiftora could not create a restore point before this migration. On a fresh or empty site, retrying is usually safe after reviewing diagnostics.', 'shiftora' ); $payload['suggested_actions'] = [ 'view_diagnostics', 'retry', 'report' ]; } elseif ( str_contains( $lower, 'cookie check failed' ) || str_contains( $lower, 'rest_cookie_invalid_nonce' ) ) { $payload['code'] = 'session_refresh_required'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'The migration finished, but your local WordPress session was refreshed after the database update. Please sign in again if prompted.', 'shiftora' ); $payload['suggested_actions'] = [ 'retry', 'view_diagnostics' ]; } elseif ( str_contains( $lower, 'cannot safely migrate its own plugin files' ) || str_contains( $lower, 'self migration' ) ) { $payload['code'] = 'self_migration_protected'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'Shiftora plugin files are selected for transfer. Keep both sites on the same Shiftora version before running a pull.', 'shiftora' ); $payload['suggested_actions'] = [ 'review_logs', 'review_permissions', 'retry' ]; } elseif ( $filesystem ) { $payload['code'] = 'environment_problem'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'This server environment is blocking the migration. Check disk space and file permissions, then retry.', 'shiftora' ); $payload['suggested_actions'] = [ 'view_diagnostics', 'retry', 'report' ]; } elseif ( ( str_contains( $lower, 'token' ) && ! str_contains( $lower, 'unexpected token' ) ) || str_contains( $lower, 'authentication' ) ) { $payload['code'] = 'authentication_failed'; $payload['severity'] = 'error'; $payload['user_message'] = __( 'Shiftora could not verify the remote site. Reconnect the sites and try again.', 'shiftora' ); $payload['suggested_actions'] = [ 'reconnect', 'report' ]; } elseif ( str_contains( $lower, 'permission' ) || str_contains( $lower, 'forbidden' ) || str_contains( $lower, 'not allowed' ) ) { $payload['code'] = 'permission_denied'; $payload['severity'] = 'error'; $payload['user_message'] = __( 'The destination site denied this action. Please verify the connection permissions.', 'shiftora' ); $payload['suggested_actions'] = [ 'review_permissions', 'report' ]; } elseif ( str_contains( $lower, 'integrity check failed' ) || str_contains( $lower, 'data loss' ) ) { $payload['code'] = 'integrity_guard'; $payload['severity'] = 'critical'; $payload['user_message'] = __( 'Shiftora stopped the migration to protect your data. Review the logs before retrying.', 'shiftora' ); $payload['suggested_actions'] = [ 'review_logs', 'report' ]; } elseif ( str_contains( $lower, 'disk space' ) || str_contains( $lower, 'writable' ) ) { $payload['code'] = 'environment_problem'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'This server environment is blocking the migration. Check disk space and file permissions, then retry.', 'shiftora' ); $payload['suggested_actions'] = [ 'view_diagnostics', 'retry', 'report' ]; } elseif ( str_contains( $lower, 'stalled' ) ) { $payload['code'] = 'migration_stalled'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'The migration appears stalled. You can resume it or stop and retry safely.', 'shiftora' ); $payload['suggested_actions'] = [ 'resume', 'retry', 'report' ]; } elseif ( str_contains( $lower, 'hash mismatch' ) || str_contains( $lower, 'remote rejected file chunk' ) || str_contains( $lower, 'invalid response from remote' ) || str_contains( $lower, 'migration stopped because' ) ) { $payload['code'] = 'file_transfer_failed'; $payload['severity'] = 'error'; $payload['user_message'] = sprintf( /* translators: %s is the original error message, often includes the file path */ __( 'File transfer failed: %s', 'shiftora' ), $safe ); $payload['suggested_actions'] = [ 'review_logs', 'view_diagnostics', 'retry', 'report' ]; } elseif ( str_contains( $lower, 'migration cannot be marked completed while file transfer work is still pending' ) ) { $payload['code'] = 'file_stage_race_detected'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'The migration was interrupted by a background task while files were still transferring. This is usually temporary, retrying the migration should resolve it.', 'shiftora' ); $payload['suggested_actions'] = [ 'retry', 'report' ]; } elseif ( str_contains( $lower, 'background worker' ) || str_contains( $lower, 'scheduler' ) ) { $payload['code'] = 'scheduler_unavailable'; $payload['severity'] = 'critical'; $payload['user_message'] = __( 'Background workers are unavailable. The scheduler needs to be active for migrations to run.', 'shiftora' ); $payload['suggested_actions'] = [ 'view_diagnostics', 'retry' ]; } elseif ( str_contains( $lower, 'manifest' ) && ( str_contains( $lower, 'failed' ) || str_contains( $lower, 'corrupt' ) || str_contains( $lower, 'invalid' ) || str_contains( $lower, 'not found' ) ) ) { $payload['code'] = 'manifest_failed'; $payload['severity'] = 'error'; $payload['user_message'] = __( 'The file manifest could not be built or downloaded. No files were transferred, so retrying is safe.', 'shiftora' ); $payload['suggested_actions'] = [ 'retry', 'report' ]; } elseif ( str_contains( $lower, 'base directory' ) && str_contains( $lower, 'not found' ) ) { $payload['code'] = 'file_stage_unavailable'; $payload['severity'] = 'error'; $payload['user_message'] = __( 'A file stage directory could not be found on the source site. Check that the site files are intact.', 'shiftora' ); $payload['suggested_actions'] = [ 'review_logs', 'report' ]; } elseif ( str_contains( $lower, 'connection not found' ) || str_contains( $lower, 'could not find connection' ) ) { $payload['code'] = 'connection_lost'; $payload['severity'] = 'critical'; $payload['user_message'] = __( 'The remote connection was lost during the migration. Reconnect the sites and try again.', 'shiftora' ); $payload['suggested_actions'] = [ 'reconnect', 'report' ]; } elseif ( str_contains( $lower, 'cannot complete' ) && str_contains( $lower, 'stage' ) && str_contains( $lower, 'was never marked completed' ) ) { $payload['code'] = 'stage_not_completed'; $payload['severity'] = 'error'; $payload['user_message'] = __( 'A stage was planned but never ran to completion. Review the stage configuration and retry.', 'shiftora' ); $payload['suggested_actions'] = [ 'review_logs', 'retry', 'report' ]; } elseif ( str_contains( $lower, 'reschedule failed' ) || str_contains( $lower, 'could not queue' ) ) { $payload['code'] = 'scheduler_unavailable'; $payload['severity'] = 'critical'; $payload['user_message'] = __( 'Background worker scheduling failed mid-migration. Resume once the scheduler is healthy.', 'shiftora' ); $payload['suggested_actions'] = [ 'view_diagnostics', 'resume', 'report' ]; } elseif ( str_contains( $lower, 'stage' ) && str_contains( $lower, 'offset state is missing' ) ) { $payload['code'] = 'file_stage_corrupt'; $payload['severity'] = 'critical'; $payload['user_message'] = __( 'The file transfer state is missing or corrupt. Retrying from scratch is recommended.', 'shiftora' ); $payload['suggested_actions'] = [ 'retry', 'report' ]; } elseif ( str_contains( $lower, 'file offset state is corrupt' ) ) { $payload['code'] = 'file_stage_corrupt'; $payload['severity'] = 'critical'; $payload['user_message'] = __( 'The file transfer state is corrupt. Retrying from scratch is recommended.', 'shiftora' ); $payload['suggested_actions'] = [ 'retry', 'report' ]; } elseif ( str_contains( $lower, 'finalize failed' ) || str_contains( $lower, 'finalize connection' ) ) { $payload['code'] = 'finalize_failed'; $payload['severity'] = 'warning'; $payload['user_message'] = __( 'The migration data was transferred but the finalization step on the remote site failed. The remote may need manual review.', 'shiftora' ); $payload['suggested_actions'] = [ 'review_logs', 'retry', 'report' ]; } return $payload; } /** * Sanitizes context arrays for UI output and support reporting. * * @param array $context * @return array */ public function sanitize_context( array $context ): array { $sanitized = []; foreach ( $context as $key => $value ) { $key_string = strtolower( (string) $key ); if ( preg_match( '/' . self::SENSITIVE_KEY_PATTERN . '/i', $key_string ) ) { $sanitized[ $key ] = '[redacted]'; continue; } if ( is_array( $value ) ) { $sanitized[ $key ] = $this->sanitize_context( $value ); continue; } // Describe objects instead of embedding them; the payload gets json_encoded on the error path. if ( is_object( $value ) ) { $sanitized[ $key ] = $value instanceof Throwable ? $value::class . ': ' . self::redact_message( $value->getMessage() ) : $value::class; continue; } if ( is_string( $value ) ) { $trimmed = self::redact_message( trim( $value ) ); $allow_path_detail = preg_match( '/(exception_file|relative_file|trace|stack|latest_php_fatal)/i', $key_string ); if ( ! $allow_path_detail && preg_match( '#^(/|[A-Za-z]:\\\\)#', $trimmed ) ) { $sanitized[ $key ] = '[path redacted]/' . basename( $trimmed ); continue; } $max_length = $allow_path_detail ? 5000 : 300; $sanitized[ $key ] = mb_strlen( $trimmed ) > $max_length ? mb_substr( $trimmed, 0, $max_length ) . '…' : $trimmed; continue; } $sanitized[ $key ] = $value; } return $sanitized; } /** * Removes credentials that arrive inside a value rather than under a suspicious * key: a DSN, a bearer token echoed by a 401, a password in a query string. * * Public so other callers that log a remote's words share one set of patterns. */ public static function redact_message( string $value ): string { $redacted = preg_replace( '#([a-z][a-z0-9+.-]*://[^\s:/@]+):([^\s@/]+)@#i', '$1:[redacted]@', $value ); // Redact the whole Authorization value, scheme included, before the bearer rule runs. $redacted = preg_replace( '/\b((?:proxy-)?authorization)\s*[=:]\s*\S+(?:\s+\S+)?/i', '$1: [redacted]', (string) $redacted ); $redacted = preg_replace( '/\b(bearer)\s+[A-Za-z0-9._~+\/-]{8,}=*/i', '$1 [redacted]', (string) $redacted ); return (string) preg_replace( '/\b(' . self::SENSITIVE_VALUE_PATTERN . ')\s*[=:]\s*("[^"]*"|\'[^\']*\'|[^\s&,;]+)/i', '$1=[redacted]', (string) $redacted ); } /** Extracts a readable message from any supported error type. */ private function extract_message( string|Throwable|WP_Error $error ): string { if ( is_string( $error ) ) { return trim( $error ); } if ( $error instanceof WP_Error ) { return trim( $error->get_error_message() ); } return trim( $error->getMessage() ); } /** * Builds the technical debug payload for support diagnostics. * * @return array */ private function extract_debug_details( string|Throwable|WP_Error $error ): array { return $this->failure_detail( $error ); } /** * Everything known about a failure, for a log row or a support payload. * * Carries the transport verdict (so a refusal is not retried like a hiccup), * the remote's own error code and the exception chain. * * @return array */ public function failure_detail( string|Throwable|WP_Error $error ): array { if ( is_string( $error ) ) { return [ 'error_type' => 'string', 'exception_message' => $this->clip( self::redact_message( $error ) ), ]; } if ( $error instanceof WP_Error ) { return $this->wp_error_detail( $error ); } $file = $error->getFile(); $relative = defined( 'ABSPATH' ) && str_starts_with( $file, ABSPATH ) ? ltrim( substr( $file, strlen( ABSPATH ) ), '/\\' ) : $file; $detail = [ 'error_type' => 'throwable', 'exception_class' => $error::class, 'exception_message' => $this->clip( self::redact_message( $error->getMessage() ) ), 'exception_file' => $file, 'relative_file' => $relative, 'exception_line' => $error->getLine(), 'trace' => $this->clip( $error->getTraceAsString(), 5000 ), ]; $chain = $this->chain( $error ); $origin = end( $chain ); // Always present so consumers can read the chain unconditionally. $detail['exception_chain'] = $chain; $detail['exception_origin'] = [ 'class' => $origin['class'], 'message' => $origin['message'], 'file' => $origin['file'], 'line' => $origin['line'], ]; if ( $error instanceof HttpRequestException ) { $detail['http_status'] = $error->http_status(); $detail['remote_code'] = $this->clip( $error->remote_code() ); $detail['retryable'] = $error->is_retryable(); // Omitted when empty, so a failure that never reached a remote differs from an empty reply. if ( $error->remote_data() ) { $detail['remote_data'] = $this->sanitize_context( $error->remote_data() ); } } return $detail; } /** * Detail for a WP_Error, including every code it carries. A receiver puts its * diagnostic detail under a second code, so reading only the first loses it. * * @return array */ private function wp_error_detail( WP_Error $error ): array { $data = $error->get_all_error_data(); return [ 'error_type' => 'wp_error', 'wp_error_code' => $error->get_error_code(), 'wp_error_codes' => $error->get_error_codes(), 'wp_error_message' => $this->clip( self::redact_message( $error->get_error_message() ) ), 'wp_error_data' => $this->sanitize_context( $data ), ]; } /** * The exception chain, outermost first, capped. * * @return array */ private function chain( Throwable $error ): array { $chain = []; for ( $current = $error; $current instanceof Throwable; $current = $current->getPrevious() ) { if ( count( $chain ) >= self::MAX_CHAIN_DEPTH ) { break; } $chain[] = [ 'class' => $current::class, 'message' => $this->clip( self::redact_message( $current->getMessage() ) ), 'file' => $current->getFile(), 'line' => $current->getLine(), ]; } return $chain; } /** Clips a value to the payload budget on a character boundary, reserving 3 bytes for the ellipsis. */ private function clip( string $value, int $limit = 255 ): string { if ( strlen( $value ) <= $limit ) { return $value; } return rtrim( Formatter::cut_utf8( $value, $limit - 3 ) ) . '…'; } }