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 / HookCallbackRoster.php

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

330 lines 14.2 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 * What is actually registered on one WordPress hook right now, described so a
9 * human can act on it.
10 *
11 * WordPress exposes `$wp_filter[$hook]->callbacks` and nothing else: a table of
12 * priorities holding entries whose `function` may be a string, a `[$object,
13 * 'method']` pair, a static pair, a closure, or an invokable. Three things make
14 * that raw table unusable as evidence, and this class owns all three:
15 *
16 * 1. ORDER. The table is not sorted, so reading it walks callbacks in
17 * insertion order rather than the order WordPress will dispatch them.
18 * 2. IDENTITY. WordPress's own registry key is `spl_object_hash($object) .
19 * $method` for an object callback, so it is a different string on every
20 * request; the resolved callable, meanwhile, reads `Closure` for anything a
21 * profiler or an APM agent has wrapped. Either one alone lies. Both are
22 * kept, so a caller can tell "removed" from "wrapped".
23 * 3. OWNERSHIP. "Which plugin put this here" is the question a hook roster
24 * exists to answer, and PHP will only tell you via reflection.
25 *
26 * Cost is split deliberately, because the caller in front of this is on a hot
27 * request path: {@see forHook} and {@see fingerprint} do no reflection at all,
28 * and only {@see describeEntriesWithOrigins} pays for it.
29 *
30 * ABJ_404_Solution_HookCallbackIdentity answers a different question about the
31 * same subject: it produces PRIVACY-HASHED identities for callbacks the plugin
32 * instruments. This class produces NAMED ones for callbacks the plugin reports
33 * on, which is what makes a support answer actionable rather than an opaque
34 * digest. Neither is a substitute for the other.
35 */
36 final class ABJ_404_Solution_HookCallbackRoster {
37
38 /**
39 * One named hook's callbacks, in dispatch order, or null when the registry
40 * is absent or is not a shape this can read.
41 *
42 * Null and empty are different answers and both are returned honestly: an
43 * empty array means the hook is there with nothing on it, and null means
44 * there was nothing to read. A caller that conflates them would report a
45 * missing registry as "every callback has been removed".
46 *
47 * @return array<int, array{priority: int, index: string, callback: string, function: mixed}>|null
48 */
49 public static function forHook(string $hookName): ?array {
50 $callbacks = self::hookCallbacks($hookName);
51 return $callbacks === null ? null : self::entries($callbacks);
52 }
53
54 /**
55 * A stable structural signature of a roster: priorities and resolved
56 * callable names, in dispatch order.
57 *
58 * Reflection-free on purpose. This is what a caller on a hot path computes
59 * to decide whether anything changed since it last looked, so it must be
60 * cheap enough to run on every request and stable enough not to differ
61 * between two identical ones. That rules out WordPress's registry keys,
62 * which carry a per-request object hash.
63 *
64 * @param array<int, array{priority: int, index: string, callback: string, function: mixed}> $entries
65 */
66 public static function fingerprint(array $entries): string {
67 $parts = array();
68 foreach ($entries as $entry) {
69 $parts[] = $entry['priority'] . ':' . $entry['callback'];
70 }
71 return substr(hash('sha256', implode('|', $parts)), 0, 16);
72 }
73
74 /**
75 * The priority a named callable is registered at, or null when it is not on
76 * the hook at all.
77 *
78 * Decided against BOTH the registry key and the resolved callable. A hook
79 * entry still keyed `redirect_canonical` is one WordPress will still
80 * dispatch, whatever a profiler wrapped the value with; matching only the
81 * resolved side would report a wrapped core callback as gone.
82 *
83 * @param array<int, array{priority: int, index: string, callback: string, function: mixed}> $entries
84 */
85 public static function priorityOf(array $entries, string $callbackName): ?int {
86 foreach ($entries as $entry) {
87 if ($entry['callback'] === $callbackName || $entry['index'] === $callbackName) {
88 return $entry['priority'];
89 }
90 }
91 return null;
92 }
93
94 /**
95 * Entries with their owning component resolved, ready to store or ship.
96 *
97 * This is the reflecting half, kept separate from everything above so a
98 * caller can fingerprint a hook on every request and only pay for
99 * attribution when it has decided the answer is worth keeping. $limit bounds
100 * both the reflection cost and the size of the result.
101 *
102 * A wrapped entry additionally carries `registered_as`: the name WordPress
103 * still has it filed under, when that differs from what the value resolves
104 * to. `{"callback":"Closure","registered_as":"redirect_canonical"}` is a
105 * complete account of a profiler-wrapped core callback, where either half
106 * alone is misleading. Volatile keys are dropped rather than reported --
107 * WordPress builds an object callback's key from spl_object_hash(), which
108 * would say nothing to a reader and differ on every request.
109 *
110 * @param array<int, array{priority: int, index: string, callback: string, function: mixed}> $entries
111 * @param int $limit how many entries to describe, from the front (earliest
112 * dispatch order) since those are the ones with the opportunity to change
113 * what the later ones see.
114 * @return array<int, array<string, mixed>>
115 */
116 public static function describeEntriesWithOrigins(array $entries, int $limit): array {
117 $described = array();
118 foreach (array_slice($entries, 0, max(0, $limit)) as $entry) {
119 $record = array(
120 'priority' => $entry['priority'],
121 'callback' => $entry['callback'],
122 'origin' => self::origin($entry['function']),
123 );
124 if ($entry['index'] !== $entry['callback'] && self::isStableIndex($entry['index'])) {
125 $record['registered_as'] = $entry['index'];
126 }
127 $described[] = $record;
128 }
129 return $described;
130 }
131
132 /**
133 * One named hook's raw callback table, or null when the registry is absent
134 * or the wrong shape.
135 *
136 * WordPress presents each hook as a WP_Hook object with a public `callbacks`
137 * table; a profiler or a very old install can present a plain array instead.
138 * Both are accepted, and anything else is reported as "nothing to read"
139 * rather than guessed at.
140 *
141 * @return array<int|string, mixed>|null
142 */
143 private static function hookCallbacks(string $hookName): ?array {
144 $wpFilter = $GLOBALS['wp_filter'] ?? null;
145 if (!is_array($wpFilter)) {
146 return null;
147 }
148 if (!array_key_exists($hookName, $wpFilter)) {
149 return array();
150 }
151 $hook = $wpFilter[$hookName];
152 if (is_object($hook) && isset($hook->callbacks) && is_array($hook->callbacks)) {
153 return $hook->callbacks;
154 }
155 if (is_array($hook)) {
156 return $hook;
157 }
158 throw new UnexpectedValueException(
159 'Malformed WordPress hook registry entry for hook ' . $hookName . '.'
160 );
161 }
162
163 /**
164 * The callback table flattened to one entry per callback, in dispatch order.
165 *
166 * The sort runs on a by-value copy. That is load-bearing rather than
167 * incidental: sorting the live WP_Hook table would reorder the callbacks
168 * WordPress is about to run, turning a read-only diagnostic into a
169 * site-wide behavior change.
170 *
171 * @param array<int|string, mixed> $callbacks
172 * @return array<int, array{priority: int, index: string, callback: string, function: mixed}>
173 */
174 private static function entries(array $callbacks): array {
175 $entries = array();
176 ksort($callbacks, SORT_NUMERIC);
177 foreach ($callbacks as $priority => $atPriority) {
178 if ((!is_int($priority) && preg_match('/^-?[0-9]+$/', $priority) !== 1)
179 || !is_array($atPriority)) {
180 throw new UnexpectedValueException(
181 'Malformed WordPress hook callback priority bucket: ' . (string)$priority . '.'
182 );
183 }
184 foreach ($atPriority as $index => $entry) {
185 if (!is_array($entry) || !array_key_exists('function', $entry)
186 || !self::isDescribableCallable($entry['function'])) {
187 throw new UnexpectedValueException(
188 'Malformed WordPress hook callback entry at priority ' . (string)$priority
189 . ' with registry key ' . (string)$index . '.'
190 );
191 }
192 $function = $entry['function'];
193 $entries[] = array(
194 'priority' => (int)$priority,
195 'index' => (string)$index,
196 'callback' => self::describeCallable($function),
197 'function' => $function,
198 );
199 }
200 }
201 return $entries;
202 }
203
204 /**
205 * Whether a registry value carries enough identity to be recorded without
206 * turning malformed data into an invented `unknown` callback.
207 *
208 * This checks shape, not callability: WordPress can retain a named callback
209 * whose class has not loaded yet, and that name is still truthful evidence.
210 *
211 * @param mixed $function
212 */
213 private static function isDescribableCallable($function): bool {
214 if (is_string($function) && $function !== '') {
215 return true;
216 }
217 if ($function instanceof Closure) {
218 return true;
219 }
220 if (is_object($function)) {
221 return true;
222 }
223 return is_array($function) && count($function) === 2
224 && (is_object($function[0]) || (is_string($function[0]) && $function[0] !== ''))
225 && is_string($function[1]) && $function[1] !== '';
226 }
227
228 /**
229 * Whether a registry key is worth reporting: a plain function name or a
230 * `Class::method` pair, rather than the spl_object_hash-prefixed key
231 * WordPress builds for an object callback.
232 */
233 private static function isStableIndex(string $index): bool {
234 return preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*(::[A-Za-z_][A-Za-z0-9_]*)?$/', $index) === 1;
235 }
236
237 /**
238 * Which component a callback came from, as `plugin:<dir>`, `mu-plugin:<dir>`,
239 * `theme:<dir>`, `wordpress-core`, or `unknown`.
240 *
241 * The component DIRECTORY is named rather than hashed, because naming the
242 * owner is the entire purpose of this field and a hash would leave the
243 * reader exactly where they started. It discloses nothing new: a support
244 * payload already carries `active_plugins` verbatim. What never leaves is
245 * the absolute path, which is site-identifying and answers nothing.
246 *
247 * @param mixed $function
248 */
249 private static function origin($function): string {
250 try {
251 $file = self::sourceFileOf($function);
252 if ($file === '') {
253 return 'unknown';
254 }
255 $normalized = str_replace('\\', '/', $file);
256 $labels = array('plugins' => 'plugin', 'mu-plugins' => 'mu-plugin', 'themes' => 'theme');
257 foreach ($labels as $directory => $label) {
258 if (preg_match('#/wp-content/' . $directory . '/([^/]+)#i', $normalized, $match) === 1) {
259 return $label . ':' . $match[1];
260 }
261 }
262 if (strpos($normalized, '/wp-includes/') !== false
263 || strpos($normalized, '/wp-admin/') !== false) {
264 return 'wordpress-core';
265 }
266 return 'unknown';
267 } catch (Throwable $e) {
268 abj404_logPhpFallback('hook-callback-roster',
269 'hook callback origin failed (code ' . $e->getCode() . '): ' . $e->getMessage()
270 . '. Recovery: verify that the registered callback target is loaded and callable, then retry the census.');
271 return 'unknown';
272 }
273 }
274
275 /**
276 * The file a callable was declared in, or '' when reflection cannot say.
277 *
278 * @param mixed $function
279 * @throws ReflectionException when the callable names a target that does not exist.
280 */
281 private static function sourceFileOf($function): string {
282 if (is_string($function) && strpos($function, '::') !== false) {
283 return (string)(new ReflectionMethod($function))->getFileName();
284 }
285 if (is_string($function)) {
286 return function_exists($function)
287 ? (string)(new ReflectionFunction($function))->getFileName() : '';
288 }
289 if (is_array($function) && count($function) === 2
290 && (is_object($function[0]) || is_string($function[0]))
291 && is_string($function[1])) {
292 return (string)(new ReflectionMethod($function[0], $function[1]))->getFileName();
293 }
294 if ($function instanceof Closure) {
295 return (string)(new ReflectionFunction($function))->getFileName();
296 }
297 if (is_object($function) && method_exists($function, '__invoke')) {
298 return (string)(new ReflectionMethod($function, '__invoke'))->getFileName();
299 }
300 return '';
301 }
302
303 /**
304 * A callable's stable name. Closures collapse to 'Closure' on purpose: two
305 * closures cannot be told apart without reflection, and {@see fingerprint}
306 * must stay reflection-free. Their declaring file still reaches a caller
307 * through origin() on the describing path.
308 *
309 * @param mixed $function
310 */
311 private static function describeCallable($function): string {
312 if (is_string($function)) {
313 return $function;
314 }
315 if (is_array($function) && count($function) === 2) {
316 $target = $function[0];
317 $owner = is_object($target) ? get_class($target)
318 : (is_string($target) ? $target : 'unknown');
319 return $owner . '::' . (is_string($function[1]) ? $function[1] : 'unknown');
320 }
321 if ($function instanceof Closure) {
322 return 'Closure';
323 }
324 if (is_object($function)) {
325 return get_class($function) . '::__invoke';
326 }
327 return 'unknown';
328 }
329 }
330