PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / diagnostics / HookCallbackIdentity.php

HookCallbackIdentity.php in 404 Solution trunk, at includes/diagnostics/HookCallbackIdentity.php

111 lines 4.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Privacy-safe identity for WordPress hook callbacks and their source code.
9 *
10 * Callback arguments and source paths never leave this class. Callables become
11 * stable hashes, and source files become either a component hash, a WordPress
12 * core label, or an opaque path hash.
13 *
14 * allow-no-test-found: exercised through real AJAX hook dispatch in tests/OptionPersistenceTracerTest.php and tests/AjaxHookCallbackAttributionTest.php
15 */
16 final class ABJ_404_Solution_HookCallbackIdentity {
17
18 private const JSON_SAFE_INTEGER_MAX = 9007199254740991;
19
20 /**
21 * @param callable $callback
22 * @return array{callback: string, source: string, has_reference: bool}
23 */
24 public static function describe(callable $callback): array {
25 $descriptor = 'callable';
26 $source = 'runtime';
27 $hasReference = false;
28 try {
29 if (is_array($callback)) {
30 $owner = is_object($callback[0]) ? get_class($callback[0]) : (string)$callback[0];
31 $descriptor = $owner . '::' . (string)$callback[1];
32 $reflection = new ReflectionMethod($callback[0], (string)$callback[1]);
33 } elseif (is_string($callback) && strpos($callback, '::') !== false) {
34 $descriptor = $callback;
35 $reflection = new ReflectionMethod($callback);
36 } elseif (is_string($callback)) {
37 $descriptor = $callback;
38 $reflection = new ReflectionFunction($callback);
39 } elseif ($callback instanceof Closure) {
40 $descriptor = 'closure';
41 $reflection = new ReflectionFunction($callback);
42 } elseif (is_object($callback)) {
43 $descriptor = get_class($callback) . '::__invoke';
44 $reflection = new ReflectionMethod($callback, '__invoke');
45 } else {
46 $reflection = new ReflectionFunction(Closure::fromCallable($callback));
47 }
48 $hasReference = $reflection->returnsReference();
49 foreach ($reflection->getParameters() as $parameter) {
50 $hasReference = $hasReference || $parameter->isPassedByReference();
51 }
52 $source = self::sourceIdentity((string)$reflection->getFileName());
53 $descriptor .= '|' . (string)$reflection->getStartLine() . '|' . $source;
54 } catch (Throwable $e) {
55 self::reportFailure(
56 'callback reflection failed (' . get_class($e) . '): ' . $e->getMessage()
57 );
58 // An unknown signature must take the marker path. Wrapping it would
59 // risk erasing reference semantics that reflection could not prove
60 // absent.
61 $hasReference = true;
62 $descriptor .= '|reflection-unavailable|' . get_class($e);
63 $source = 'unavailable';
64 }
65 return array(
66 'callback' => 'cb#' . substr(hash('sha256', $descriptor), 0, 12),
67 'source' => $source,
68 'has_reference' => $hasReference,
69 );
70 }
71
72 public static function hookName(string $value): string {
73 if (preg_match('/^[A-Za-z_][A-Za-z0-9_.:-]{0,63}$/', $value) === 1) {
74 return preg_replace('/[0-9]+/', '#', $value) ?? '';
75 }
76 return 'hook#' . substr(hash('sha256', $value), 0, 12);
77 }
78
79 /**
80 * Preserve a hook priority exactly when diagnostic JSON crosses a
81 * JavaScript reader. WordPress still receives the original priority.
82 */
83 public static function jsonSafePriority(?int $priority): ?int {
84 if ($priority === null || PHP_INT_SIZE < 8) {
85 return $priority;
86 }
87 return max(
88 -self::JSON_SAFE_INTEGER_MAX,
89 min(self::JSON_SAFE_INTEGER_MAX, $priority)
90 );
91 }
92
93 private static function sourceIdentity(string $file): string {
94 $normalized = str_replace('\\', '/', $file);
95 foreach (array('plugins' => 'plugin', 'mu-plugins' => 'mu', 'themes' => 'theme') as $part => $label) {
96 if (preg_match('#/wp-content/' . $part . '/([^/]+)#i', $normalized, $match) === 1) {
97 return $label . '#' . substr(hash('sha256', strtolower($match[1])), 0, 12);
98 }
99 }
100 if (strpos($normalized, '/wp-includes/') !== false || strpos($normalized, '/wp-admin/') !== false) {
101 return 'wordpress-core';
102 }
103 return $normalized === '' ? 'php-runtime'
104 : 'source#' . substr(hash('sha256', $normalized), 0, 12);
105 }
106
107 private static function reportFailure(string $message): void {
108 abj404_logPhpFallback('hook-callback-identity', $message);
109 }
110 }
111