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

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

372 lines 17.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 * Is the JavaScript that executed in the browser the JavaScript this install
9 * shipped? (Bruno timeout cause matrix, cause A8 "wrong, duplicated, or stale
10 * client JS", coverage req. 6, gap GF / Codex gap #8.)
11 *
12 * The admin table has now been rewritten three times server-side without the
13 * user-visible symptom changing, which makes the client bundle one of the few
14 * remaining invariants. Asking the browser for a version string it was told by
15 * the same page would prove nothing: a page rendered by fresh PHP always
16 * reports the fresh value even when the browser is executing a bundle out of
17 * an edge or disk cache. So the browser instead hashes the source text of the
18 * modules as they are actually executing (Function.prototype.toString), and
19 * this class independently hashes the same text out of the shipped .js files.
20 * Equal hashes prove the executing bytes are the shipped bytes. Unequal hashes
21 * prove they are not, whether the cause is a stale cache, a second copy of the
22 * plugin's JS on the page, or an optimizer rewriting the bundle in flight.
23 *
24 * The first version of this probe covered ONE function in ONE telemetry file,
25 * which left the request driver, transport, storage, delivery, canary, and
26 * support modules unproven -- exactly the modules a stalled table request
27 * lives in. The manifest below covers all of them, and the verdict is now
28 * per-module: a mismatch NAMES the drifted file instead of only announcing
29 * that the client is not what we shipped.
30 *
31 * Nothing is kept in sync by hand: editing a module changes both sides at
32 * once, because both sides derive their value from the same file. Two module
33 * shapes are supported, matching how the modules are actually written:
34 *
35 * - `markers`: the module body is a single function expression delimited by
36 * the marker comments (every IIFE-shaped module). The browser hashes
37 * String(moduleFn); this class hashes the text between the markers.
38 * - `functions`: the module is flat top-level function declarations. The
39 * browser hashes their sources joined by newlines, in file order; this
40 * class extracts the same named functions from the file and joins them
41 * the same way.
42 */
43 final class ABJ_404_Solution_ClientBuildFingerprint {
44
45 /** Markers delimiting a whole-module function expression. Comments, so they never reach toString(). */
46 const START_MARKER = '/* abj404-client-module:start */';
47 const END_MARKER = '/* abj404-client-module:end */';
48
49 /**
50 * Every diagnostic client module, keyed by the short name the browser
51 * registers it under (view_updater_client_build_registry.js). `functions`
52 * is null for marker-delimited modules and an ordered list of top-level
53 * function names for flat ones.
54 *
55 * Paths are relative to includes/. ClientBuildFingerprintTest pins this
56 * table against the modules' own registration calls, so the two lists
57 * cannot drift apart silently.
58 */
59 const MODULES = array(
60 'tab_identity' => array('file' => 'ajax/view_updater_client_tab_identity.js', 'functions' => null),
61 'tab_presence' => array('file' => 'ajax/view_updater_client_tab_presence.js', 'functions' => null),
62 'page_ajax_activity' => array('file' => 'ajax/view_updater_page_ajax_activity.js', 'functions' => null),
63 'attempt_buffer' => array('file' => 'ajax/view_updater_client_attempt_buffer.js', 'functions' => null),
64 'telemetry_store' => array('file' => 'ajax/view_updater_client_telemetry_store.js', 'functions' => null),
65 'canary_cooldown' => array('file' => 'ajax/view_updater_canary_cooldown.js', 'functions' => null),
66 'main_thread_observations' => array(
67 'file' => 'ajax/view_updater_client_main_thread_observations.js', 'functions' => null),
68 'telemetry_env' => array('file' => 'ajax/view_updater_client_telemetry_env.js', 'functions' => null),
69 'resource_timing' => array(
70 'file' => 'ajax/view_updater_client_resource_timing.js', 'functions' => null),
71 'response_body_shape' => array(
72 'file' => 'ajax/view_updater_response_body_shape.js', 'functions' => null),
73 'transport_telemetry' => array('file' => 'ajax/view_updater_transport_telemetry.js', 'functions' => null),
74 'telemetry_delivery' => array('file' => 'ajax/view_updater_transport_telemetry_delivery.js', 'functions' => null),
75 'health_bar' => array(
76 'file' => 'ajax/view_updater_health_bar.js',
77 'functions' => array('abj404HealthBarState', 'abj404HealthBarFragmentParts',
78 'abj404BuildHealthBarFragment', 'abj404RetireHealthBar',
79 'abj404HealthBarTelemetry',
80 'abj404HealthBarAttemptUrl', 'abj404HealthBarOutcome',
81 'abj404HealthBarReportNonce',
82 'abj404HealthBarRequestPlan', 'abj404BeginHealthBarRequest',
83 'abj404RenderHealthBarResult',
84 'abj404HealthBarAjaxOptions', 'refreshHealthBarIfNeeded')),
85 'canary_measurements' => array(
86 'file' => 'ajax/view_updater_canary_measurements.js', 'functions' => null),
87 'concurrent_control_evidence' => array(
88 'file' => 'ajax/view_updater_concurrent_control_evidence.js', 'functions' => null),
89 'canary_ladder' => array('file' => 'ajax/view_updater_canary_ladder.js', 'functions' => null),
90 'support_request' => array('file' => 'ajax/SupportRequest.js', 'functions' => null),
91 'pagination_request' => array(
92 'file' => 'ajax/view_updater_pagination_request.js',
93 'functions' => array('abj404BuildPaginationRequest'),
94 ),
95 'pagination_transport' => array(
96 'file' => 'ajax/view_updater_pagination_transport.js',
97 'functions' => array(
98 'abj404PaginationResponseHasStructuredError',
99 'abj404PaginationFailureIsTransient',
100 'abj404PaginationTelemetry',
101 'abj404PaginationTelemetryDelivery',
102 'abj404PaginationServerOperationThresholdMs',
103 'abj404ConcurrentControlRelay',
104 'abj404PaginationAttemptUrl',
105 'abj404PaginationAttemptData',
106 'abj404RequestPaginationPart',
107 'abj404PaginationOutcome',
108 ),
109 ),
110 'stage_diagnostics' => array(
111 'file' => 'ajax/view_updater_stage_diagnostics.js',
112 'functions' => array('abj404AjaxStageDiagnostics'),
113 ),
114 );
115
116 /** Hard bound on the reported per-module wire string, before parsing. */
117 const MAX_REPORTED_MODULES_BYTES = 1024;
118
119 /** @var array<string, string>|null Memoized per request; the files cannot change mid-request. */
120 private static $expectedModuleHashes = null;
121
122 /**
123 * FNV-1a, 32 bit, lowercase hex, byte for byte identical to the client's
124 * abj404ClientBuildRegistry.fnv1a32(). Written as shift-and-add with an
125 * explicit 32-bit mask after every step for exactly one reason: the
126 * JavaScript side truncates to 32 bits on every shift, so a PHP `*
127 * 16777619` on a 64-bit build would silently diverge and turn every
128 * comparison into a false mismatch.
129 *
130 * PHP strings are already bytes, so this walks them directly; the client
131 * encodes its UTF-16 string to UTF-8 first so both sides hash the same
132 * sequence. That is not theoretical: a module source containing one em
133 * dash used to hash differently on the two sides and would have reported
134 * every healthy browser as running a stale build.
135 */
136 public static function hashOf(string $text): string {
137 $hash = 0x811c9dc5;
138 $length = strlen($text);
139 for ($i = 0; $i < $length; $i++) {
140 $hash ^= ord($text[$i]);
141 $hash = ($hash
142 + ((($hash << 1) & 0xffffffff)
143 + (($hash << 4) & 0xffffffff)
144 + (($hash << 7) & 0xffffffff)
145 + (($hash << 8) & 0xffffffff)
146 + (($hash << 24) & 0xffffffff))) & 0xffffffff;
147 }
148 return str_pad(dechex($hash), 8, '0', STR_PAD_LEFT);
149 }
150
151 /**
152 * The shipped source text for one module, normalized exactly the way the
153 * browser normalizes its own (carriage returns stripped, surrounding
154 * whitespace trimmed), or '' when the file or its markers/functions
155 * cannot be read.
156 */
157 public static function expectedModuleSource(string $name): string {
158 $module = self::MODULES[$name] ?? null;
159 if (!is_array($module)) {
160 return '';
161 }
162 $path = dirname(__DIR__) . DIRECTORY_SEPARATOR
163 . str_replace('/', DIRECTORY_SEPARATOR, (string)$module['file']);
164 $contents = @file_get_contents($path);
165 if (!is_string($contents) || $contents === '') {
166 return '';
167 }
168 $contents = str_replace("\r", '', $contents);
169 $functions = $module['functions'] ?? null;
170 return is_array($functions)
171 ? self::joinedFunctionSources($contents, $functions)
172 : self::markedRegion($contents);
173 }
174
175 /** The text between the module markers, or '' when they are absent. */
176 private static function markedRegion(string $contents): string {
177 $start = strpos($contents, self::START_MARKER);
178 $end = strrpos($contents, self::END_MARKER);
179 if ($start === false || $end === false || $end <= $start) {
180 return '';
181 }
182 $start += strlen(self::START_MARKER);
183 return trim(substr($contents, $start, $end - $start));
184 }
185
186 /**
187 * Top-level function declarations joined by newlines, in the given order,
188 * matching what the browser produces from the same function references.
189 * Any function that cannot be located makes the whole module unreadable
190 * ('') rather than silently hashing a subset: a partial hash would read
191 * as a mismatch and send the investigation after a phantom.
192 *
193 * @param array<int, string> $functionNames
194 */
195 private static function joinedFunctionSources(string $contents, array $functionNames): string {
196 $sources = array();
197 foreach ($functionNames as $name) {
198 $source = self::topLevelFunctionSource($contents, (string)$name);
199 if ($source === '') {
200 return '';
201 }
202 $sources[] = $source;
203 }
204 return implode("\n", $sources);
205 }
206
207 /**
208 * One top-level function declaration's exact source text: from the
209 * `function` keyword at column zero through the matching closing brace,
210 * which the project's JS style always puts at column zero too. That is
211 * precisely the text Function.prototype.toString() returns for the same
212 * function, so the two sides agree without either parsing JavaScript.
213 */
214 private static function topLevelFunctionSource(string $contents, string $name): string {
215 if (preg_match('/^function\s+' . preg_quote($name, '/') . '\s*\(/m', $contents, $matches,
216 PREG_OFFSET_CAPTURE) !== 1) {
217 return '';
218 }
219 $start = (int)$matches[0][1];
220 $end = strpos($contents, "\n}\n", $start);
221 if ($end === false) {
222 // The last declaration in a file with no trailing blank line.
223 $end = substr($contents, -2) === "\n}" ? strlen($contents) - 2 : false;
224 }
225 if ($end === false) {
226 return '';
227 }
228 return substr($contents, $start, ($end + 2) - $start);
229 }
230
231 /**
232 * Shipped hash per module, '' for any module that could not be read.
233 *
234 * @return array<string, string>
235 */
236 public static function expectedModuleHashes(): array {
237 if (self::$expectedModuleHashes === null) {
238 $hashes = array();
239 foreach (array_keys(self::MODULES) as $name) {
240 $source = self::expectedModuleSource((string)$name);
241 $hashes[(string)$name] = $source === '' ? '' : self::hashOf($source);
242 }
243 self::$expectedModuleHashes = $hashes;
244 }
245 return self::$expectedModuleHashes;
246 }
247
248 /**
249 * The combined hash the browser would report for a given set of module
250 * names: FNV-1a over the sorted `name:hash` pairs, exactly as
251 * abj404ClientBuildRegistry.digest() computes it. Computed for the
252 * reported SUBSET rather than for every module, because an admin screen
253 * that loads the support client but not the canary ladder is a smaller
254 * module set, not a stale one.
255 *
256 * @param array<int, string> $names
257 */
258 public static function expectedCombinedHash(array $names): string {
259 $expected = self::expectedModuleHashes();
260 $names = array_values(array_unique(array_filter($names, static function ($name) use ($expected) {
261 return isset($expected[$name]) && $expected[$name] !== '';
262 })));
263 if ($names === array()) {
264 return '';
265 }
266 sort($names);
267 $parts = array();
268 foreach ($names as $name) {
269 $parts[] = $name . ':' . $expected[$name];
270 }
271 return self::hashOf(implode('|', $parts));
272 }
273
274 /**
275 * The combined hash a page that loaded EVERY shipped diagnostic module
276 * would report. Also what compare() falls back to when a client sends a
277 * combined hash without the per-module breakdown (an older client, or one
278 * whose registry failed to load).
279 */
280 public static function expectedHash(): string {
281 return self::expectedCombinedHash(array_keys(self::MODULES));
282 }
283
284 /** Test seam: drop the memoized hashes so rewritten module files are re-read. */
285 public static function resetMemoizedHash(): void {
286 self::$expectedModuleHashes = null;
287 }
288
289 /**
290 * Parse the client's compact `name:hash,name:hash` module string into a
291 * map, discarding anything that is not a known module name paired with a
292 * well-formed hash. Untrusted browser text: bounded before parsing and
293 * never echoed back.
294 *
295 * @return array<string, string>
296 */
297 public static function parseReportedModules(string $raw): array {
298 $modules = array();
299 foreach (explode(',', substr($raw, 0, self::MAX_REPORTED_MODULES_BYTES)) as $pair) {
300 $parts = explode(':', trim($pair), 2);
301 if (count($parts) !== 2) {
302 continue;
303 }
304 $name = $parts[0];
305 if (isset(self::MODULES[$name]) && preg_match('/^[0-9a-f]{8}$/', $parts[1]) === 1) {
306 $modules[$name] = $parts[1];
307 }
308 }
309 return $modules;
310 }
311
312 /**
313 * Compare what the browser reported against what this install shipped.
314 *
315 * The verdict is deliberately three-valued. "unknown" (either side could
316 * not produce a hash) must never be reported as a match, because the whole
317 * point of this probe is that a missing answer is itself evidence.
318 *
319 * `mismatched_modules` is the actionable half: when the combined hashes
320 * disagree it names every module whose own hash disagrees, so a support
321 * payload says which file drifted rather than only that something did.
322 * `unreported_modules` names modules this install ships that the page did
323 * not register at all -- a module that failed to load looks identical to
324 * a healthy smaller page unless it is stated.
325 *
326 * @return array{reported: string, expected: string, verdict: string,
327 * reported_module_count: int, mismatched_modules: array<int, string>,
328 * unreported_modules: array<int, string>}
329 */
330 public static function compare(string $reportedHash, string $reportedModules = ''): array {
331 $modules = self::parseReportedModules($reportedModules);
332 $expected = self::expectedModuleHashes();
333 $expectedCombined = $modules === array()
334 ? self::expectedCombinedHash(array_keys($expected))
335 : self::expectedCombinedHash(array_keys($modules));
336 $reported = preg_match('/^[0-9a-f]{8}$/', $reportedHash) === 1 ? $reportedHash : '';
337
338 $mismatched = array();
339 foreach ($modules as $name => $hash) {
340 if (($expected[$name] ?? '') !== '' && !hash_equals($expected[$name], $hash)) {
341 $mismatched[] = $name;
342 }
343 }
344
345 // A drifted module is a mismatch even when the combined hash agrees.
346 // The client derives its combined value from these same per-module
347 // hashes, so the two can only disagree if something rewrote one of
348 // them in transit -- and "the summary says fine while a component
349 // says otherwise" is the one reading that must never be reported as
350 // healthy.
351 if ($expectedCombined === '' || $reported === '') {
352 $verdict = 'unknown';
353 } elseif (hash_equals($expectedCombined, $reported) && $mismatched === array()) {
354 $verdict = 'match';
355 } else {
356 $verdict = 'mismatch';
357 }
358 $unreported = $modules === array()
359 ? array()
360 : array_values(array_diff(array_keys($expected), array_keys($modules)));
361
362 return array(
363 'reported' => $reported,
364 'expected' => $expectedCombined,
365 'verdict' => $verdict,
366 'reported_module_count' => count($modules),
367 'mismatched_modules' => $mismatched,
368 'unreported_modules' => $unreported,
369 );
370 }
371 }
372