array('file' => 'ajax/view_updater_client_tab_identity.js', 'functions' => null), 'tab_presence' => array('file' => 'ajax/view_updater_client_tab_presence.js', 'functions' => null), 'page_ajax_activity' => array('file' => 'ajax/view_updater_page_ajax_activity.js', 'functions' => null), 'attempt_buffer' => array('file' => 'ajax/view_updater_client_attempt_buffer.js', 'functions' => null), 'telemetry_store' => array('file' => 'ajax/view_updater_client_telemetry_store.js', 'functions' => null), 'canary_cooldown' => array('file' => 'ajax/view_updater_canary_cooldown.js', 'functions' => null), 'main_thread_observations' => array( 'file' => 'ajax/view_updater_client_main_thread_observations.js', 'functions' => null), 'telemetry_env' => array('file' => 'ajax/view_updater_client_telemetry_env.js', 'functions' => null), 'resource_timing' => array( 'file' => 'ajax/view_updater_client_resource_timing.js', 'functions' => null), 'response_body_shape' => array( 'file' => 'ajax/view_updater_response_body_shape.js', 'functions' => null), 'transport_telemetry' => array('file' => 'ajax/view_updater_transport_telemetry.js', 'functions' => null), 'telemetry_delivery' => array('file' => 'ajax/view_updater_transport_telemetry_delivery.js', 'functions' => null), 'health_bar' => array( 'file' => 'ajax/view_updater_health_bar.js', 'functions' => array('abj404HealthBarState', 'abj404HealthBarFragmentParts', 'abj404BuildHealthBarFragment', 'abj404RetireHealthBar', 'abj404HealthBarTelemetry', 'abj404HealthBarAttemptUrl', 'abj404HealthBarOutcome', 'abj404HealthBarReportNonce', 'abj404HealthBarRequestPlan', 'abj404BeginHealthBarRequest', 'abj404RenderHealthBarResult', 'abj404HealthBarAjaxOptions', 'refreshHealthBarIfNeeded')), 'canary_measurements' => array( 'file' => 'ajax/view_updater_canary_measurements.js', 'functions' => null), 'concurrent_control_evidence' => array( 'file' => 'ajax/view_updater_concurrent_control_evidence.js', 'functions' => null), 'canary_ladder' => array('file' => 'ajax/view_updater_canary_ladder.js', 'functions' => null), 'support_request' => array('file' => 'ajax/SupportRequest.js', 'functions' => null), 'pagination_request' => array( 'file' => 'ajax/view_updater_pagination_request.js', 'functions' => array('abj404BuildPaginationRequest'), ), 'pagination_transport' => array( 'file' => 'ajax/view_updater_pagination_transport.js', 'functions' => array( 'abj404PaginationResponseHasStructuredError', 'abj404PaginationFailureIsTransient', 'abj404PaginationTelemetry', 'abj404PaginationTelemetryDelivery', 'abj404PaginationServerOperationThresholdMs', 'abj404ConcurrentControlRelay', 'abj404PaginationAttemptUrl', 'abj404PaginationAttemptData', 'abj404RequestPaginationPart', 'abj404PaginationOutcome', ), ), 'stage_diagnostics' => array( 'file' => 'ajax/view_updater_stage_diagnostics.js', 'functions' => array('abj404AjaxStageDiagnostics'), ), ); /** Hard bound on the reported per-module wire string, before parsing. */ const MAX_REPORTED_MODULES_BYTES = 1024; /** @var array|null Memoized per request; the files cannot change mid-request. */ private static $expectedModuleHashes = null; /** * FNV-1a, 32 bit, lowercase hex, byte for byte identical to the client's * abj404ClientBuildRegistry.fnv1a32(). Written as shift-and-add with an * explicit 32-bit mask after every step for exactly one reason: the * JavaScript side truncates to 32 bits on every shift, so a PHP `* * 16777619` on a 64-bit build would silently diverge and turn every * comparison into a false mismatch. * * PHP strings are already bytes, so this walks them directly; the client * encodes its UTF-16 string to UTF-8 first so both sides hash the same * sequence. That is not theoretical: a module source containing one em * dash used to hash differently on the two sides and would have reported * every healthy browser as running a stale build. */ public static function hashOf(string $text): string { $hash = 0x811c9dc5; $length = strlen($text); for ($i = 0; $i < $length; $i++) { $hash ^= ord($text[$i]); $hash = ($hash + ((($hash << 1) & 0xffffffff) + (($hash << 4) & 0xffffffff) + (($hash << 7) & 0xffffffff) + (($hash << 8) & 0xffffffff) + (($hash << 24) & 0xffffffff))) & 0xffffffff; } return str_pad(dechex($hash), 8, '0', STR_PAD_LEFT); } /** * The shipped source text for one module, normalized exactly the way the * browser normalizes its own (carriage returns stripped, surrounding * whitespace trimmed), or '' when the file or its markers/functions * cannot be read. */ public static function expectedModuleSource(string $name): string { $module = self::MODULES[$name] ?? null; if (!is_array($module)) { return ''; } $path = dirname(__DIR__) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string)$module['file']); $contents = @file_get_contents($path); if (!is_string($contents) || $contents === '') { return ''; } $contents = str_replace("\r", '', $contents); $functions = $module['functions'] ?? null; return is_array($functions) ? self::joinedFunctionSources($contents, $functions) : self::markedRegion($contents); } /** The text between the module markers, or '' when they are absent. */ private static function markedRegion(string $contents): string { $start = strpos($contents, self::START_MARKER); $end = strrpos($contents, self::END_MARKER); if ($start === false || $end === false || $end <= $start) { return ''; } $start += strlen(self::START_MARKER); return trim(substr($contents, $start, $end - $start)); } /** * Top-level function declarations joined by newlines, in the given order, * matching what the browser produces from the same function references. * Any function that cannot be located makes the whole module unreadable * ('') rather than silently hashing a subset: a partial hash would read * as a mismatch and send the investigation after a phantom. * * @param array $functionNames */ private static function joinedFunctionSources(string $contents, array $functionNames): string { $sources = array(); foreach ($functionNames as $name) { $source = self::topLevelFunctionSource($contents, (string)$name); if ($source === '') { return ''; } $sources[] = $source; } return implode("\n", $sources); } /** * One top-level function declaration's exact source text: from the * `function` keyword at column zero through the matching closing brace, * which the project's JS style always puts at column zero too. That is * precisely the text Function.prototype.toString() returns for the same * function, so the two sides agree without either parsing JavaScript. */ private static function topLevelFunctionSource(string $contents, string $name): string { if (preg_match('/^function\s+' . preg_quote($name, '/') . '\s*\(/m', $contents, $matches, PREG_OFFSET_CAPTURE) !== 1) { return ''; } $start = (int)$matches[0][1]; $end = strpos($contents, "\n}\n", $start); if ($end === false) { // The last declaration in a file with no trailing blank line. $end = substr($contents, -2) === "\n}" ? strlen($contents) - 2 : false; } if ($end === false) { return ''; } return substr($contents, $start, ($end + 2) - $start); } /** * Shipped hash per module, '' for any module that could not be read. * * @return array */ public static function expectedModuleHashes(): array { if (self::$expectedModuleHashes === null) { $hashes = array(); foreach (array_keys(self::MODULES) as $name) { $source = self::expectedModuleSource((string)$name); $hashes[(string)$name] = $source === '' ? '' : self::hashOf($source); } self::$expectedModuleHashes = $hashes; } return self::$expectedModuleHashes; } /** * The combined hash the browser would report for a given set of module * names: FNV-1a over the sorted `name:hash` pairs, exactly as * abj404ClientBuildRegistry.digest() computes it. Computed for the * reported SUBSET rather than for every module, because an admin screen * that loads the support client but not the canary ladder is a smaller * module set, not a stale one. * * @param array $names */ public static function expectedCombinedHash(array $names): string { $expected = self::expectedModuleHashes(); $names = array_values(array_unique(array_filter($names, static function ($name) use ($expected) { return isset($expected[$name]) && $expected[$name] !== ''; }))); if ($names === array()) { return ''; } sort($names); $parts = array(); foreach ($names as $name) { $parts[] = $name . ':' . $expected[$name]; } return self::hashOf(implode('|', $parts)); } /** * The combined hash a page that loaded EVERY shipped diagnostic module * would report. Also what compare() falls back to when a client sends a * combined hash without the per-module breakdown (an older client, or one * whose registry failed to load). */ public static function expectedHash(): string { return self::expectedCombinedHash(array_keys(self::MODULES)); } /** Test seam: drop the memoized hashes so rewritten module files are re-read. */ public static function resetMemoizedHash(): void { self::$expectedModuleHashes = null; } /** * Parse the client's compact `name:hash,name:hash` module string into a * map, discarding anything that is not a known module name paired with a * well-formed hash. Untrusted browser text: bounded before parsing and * never echoed back. * * @return array */ public static function parseReportedModules(string $raw): array { $modules = array(); foreach (explode(',', substr($raw, 0, self::MAX_REPORTED_MODULES_BYTES)) as $pair) { $parts = explode(':', trim($pair), 2); if (count($parts) !== 2) { continue; } $name = $parts[0]; if (isset(self::MODULES[$name]) && preg_match('/^[0-9a-f]{8}$/', $parts[1]) === 1) { $modules[$name] = $parts[1]; } } return $modules; } /** * Compare what the browser reported against what this install shipped. * * The verdict is deliberately three-valued. "unknown" (either side could * not produce a hash) must never be reported as a match, because the whole * point of this probe is that a missing answer is itself evidence. * * `mismatched_modules` is the actionable half: when the combined hashes * disagree it names every module whose own hash disagrees, so a support * payload says which file drifted rather than only that something did. * `unreported_modules` names modules this install ships that the page did * not register at all -- a module that failed to load looks identical to * a healthy smaller page unless it is stated. * * @return array{reported: string, expected: string, verdict: string, * reported_module_count: int, mismatched_modules: array, * unreported_modules: array} */ public static function compare(string $reportedHash, string $reportedModules = ''): array { $modules = self::parseReportedModules($reportedModules); $expected = self::expectedModuleHashes(); $expectedCombined = $modules === array() ? self::expectedCombinedHash(array_keys($expected)) : self::expectedCombinedHash(array_keys($modules)); $reported = preg_match('/^[0-9a-f]{8}$/', $reportedHash) === 1 ? $reportedHash : ''; $mismatched = array(); foreach ($modules as $name => $hash) { if (($expected[$name] ?? '') !== '' && !hash_equals($expected[$name], $hash)) { $mismatched[] = $name; } } // A drifted module is a mismatch even when the combined hash agrees. // The client derives its combined value from these same per-module // hashes, so the two can only disagree if something rewrote one of // them in transit -- and "the summary says fine while a component // says otherwise" is the one reading that must never be reported as // healthy. if ($expectedCombined === '' || $reported === '') { $verdict = 'unknown'; } elseif (hash_equals($expectedCombined, $reported) && $mismatched === array()) { $verdict = 'match'; } else { $verdict = 'mismatch'; } $unreported = $modules === array() ? array() : array_values(array_diff(array_keys($expected), array_keys($modules))); return array( 'reported' => $reported, 'expected' => $expectedCombined, 'verdict' => $verdict, 'reported_module_count' => count($modules), 'mismatched_modules' => $mismatched, 'unreported_modules' => $unreported, ); } }