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

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

294 lines 13.0 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 * Which bytes on disk make up the diagnostic request path, and is the opcode
9 * cache serving those same bytes? (Bruno timeout cause matrix, gap GF /
10 * Codex gap #8, cause D "stale or mixed code".)
11 *
12 * `request_start` used to fingerprint exactly two files: this request's
13 * handler and ABJ_404_Solution_RequestEnvironmentFingerprint itself. That
14 * proved nothing about the modules a stalled table request actually spends
15 * its time in -- the request driver, the checkpoint recorder, the journal,
16 * the response emitter, the canary ladder, the support collector. A mixed
17 * OPcache generation (one module recompiled after a deploy, its neighbours
18 * still running the previous release's opcodes) is invisible to a two-file
19 * probe, and mixed generations are exactly what produces a symptom that
20 * survives three rewrites of the code the symptom appears in.
21 *
22 * So the manifest covers the whole path, and it is DERIVED rather than
23 * listed where it can be: every file in includes/diagnostics/ is in scope by
24 * construction, so a diagnostic module added next month is covered without
25 * anyone remembering to add it here. Only the collaborators that live
26 * elsewhere (dispatch, response emission, the endpoints themselves, the DB
27 * query hook, asset delivery) are named explicitly, and
28 * DiagnosticModuleManifestTest pins that list against the files those
29 * endpoints are actually implemented in.
30 *
31 * The captured record is deliberately compact: one combined hash, per-file
32 * short hashes so a drifted file can be NAMED when a payload is compared
33 * against a reference build, counts for the healthy majority, and names only
34 * for the anomalies (unreadable, uncached, or an OPcache timestamp that does
35 * not match the file on disk). The support excerpt is the scarce resource
36 * (gap G1); a full per-file fingerprint block for thirty-odd modules would
37 * cost more evidence than it produces.
38 */
39 final class ABJ_404_Solution_DiagnosticModuleManifest {
40
41 /** Bumped when the record's shape changes, so an old payload stays readable. */
42 const SCHEMA_VERSION = 1;
43
44 /** Characters of each file's md5 kept per module. Enough to name a drifted file. */
45 const SHORT_HASH_CHARS = 8;
46
47 /**
48 * Modules outside includes/diagnostics/ that a table AJAX request, its
49 * telemetry, the canary ladder, or the support collector executes.
50 *
51 * Paths are relative to includes/. Anything under diagnostics/ is picked
52 * up automatically and must NOT be listed here.
53 */
54 const EXTERNAL_MODULES = array(
55 // The plugin entry point owns the earliest compiled build marker.
56 '../404-solution.php',
57 // Request path: dispatch -> auth -> rate limit -> handler -> stages.
58 'ajax/AjaxAdminEndpointRegistrar.php',
59 'ajax/AjaxSecurityGate.php',
60 'ajax/Ajax_Php.php',
61 'ajax/AjaxAdminEndpointSupport.php',
62 'ajax/Ajax_GetPaginationLinks.php',
63 'ajax/Ajax_RefreshHealthBar.php',
64 // AjaxStageDiagnostics.php is deliberately absent: it now lives under
65 // diagnostics/ and is therefore picked up automatically.
66 // The report-only beacon branch of the table endpoint.
67 'ajax/AjaxClientReportBeaconResponder.php',
68 // Response emission and connection detach.
69 'ajax/AjaxResponseEmitter.php',
70 // Canary ladder and support collection endpoints. The ladder's endpoint
71 // and its step execution are separate files and both are covered: a
72 // mixed OPcache generation across the two would leave the ladder
73 // authenticating on one build and probing on another.
74 'ajax/Ajax_CanaryLadder.php',
75 'ajax/AjaxCanaryStepRunner.php',
76 'ajax/Ajax_SupportRequest.php',
77 'ajax/Ajax_SupportRequestPreview.php',
78 // Request parsing is part of the canary interpretation boundary.
79 'services/RequestInputNormalizer.php',
80 // Local template I/O boundary used by the table renderers.
81 'core/FileSystemService.php',
82 // Pre-query sort-readiness schema and option/cache authority.
83 'view-build/RedirectsDenormSchemaReadiness.php',
84 // Foreground status-count cache and cron scheduling authorities.
85 'stats/StatusCountsRepository.php',
86 'stats/RedirectHitCountHistogramRepository.php',
87 'stats/RedirectRowCountRepository.php',
88 'view-build/StatusCountsRefreshCoordinator.php',
89 'services/CronScheduler.php',
90 // The per-query attribution hook, which lives with the DB layer.
91 'database/DatabaseQueryDiagnostics.php',
92 // Query preflight spans these DB collaborators before the first SQL
93 // probe. Mixed opcodes in any one would erase or mislabel the gap.
94 'database/DatabaseQueryExecutor.php',
95 'database/DatabaseQueryRecoveryPolicy.php',
96 'database/DatabaseRepairPolicy.php',
97 'database/DatabaseTableRepairer.php',
98 'database/DatabaseSqlErrorReporter.php',
99 'database/DatabaseConnectionManager.php',
100 'database/DatabaseQueryTimeoutManager.php',
101 'database/DatabaseRuntimeState.php',
102 // Delivery of the browser-side modules ClientBuildFingerprint hashes.
103 'admin/AdminAssetEnqueuer.php',
104 );
105
106 /**
107 * Absolute paths of every module in scope, keyed by the short name the
108 * record reports. Recomputed per call rather than memoized in a static:
109 * an FPM worker serves many requests, and a manifest cached across a
110 * deploy would report the pre-deploy file set as if it were current --
111 * which is the exact blind spot this class exists to remove.
112 *
113 * @return array<string, string>
114 */
115 public static function modulePaths(): array {
116 $paths = self::baseModulePathsForRoot(dirname(dirname(__DIR__)));
117 // Real extension point, not a test hatch: a site running this plugin
118 // from an unusual layout (a symlinked mu-plugin tree, a build that
119 // relocates part of the diagnostic path) can tell the manifest where
120 // its modules actually live, and an integrator adding a diagnostic
121 // module of their own can have it fingerprinted alongside ours. A
122 // filter that returns anything but a non-empty array is ignored, so a
123 // careless callback degrades to the shipped list rather than to an
124 // empty manifest that would read as "no code is deployed".
125 if (!function_exists('apply_filters')) {
126 return $paths;
127 }
128 $filtered = apply_filters('abj404_diagnostic_module_paths', $paths);
129 if (!is_array($filtered) || $filtered === array()) {
130 return $paths;
131 }
132 // Re-typed key by key rather than passed through: the filtered value
133 // is whatever a third party returned, so an array key (always int or
134 // string) and a value of any type are normalized here rather than
135 // trusted downstream. A non-scalar path is dropped, not stringified.
136 $result = array();
137 foreach ($filtered as $name => $path) {
138 if (is_scalar($path)) {
139 $result[(string)$name] = (string)$path;
140 }
141 }
142 return $result === array() ? $paths : $result;
143 }
144
145 /**
146 * Build the unfiltered shipped manifest for a project root supplied as
147 * data. Release tooling uses this instead of executing PHP from that root.
148 *
149 * @return array<string, string>
150 */
151 private static function baseModulePathsForRoot(string $projectRoot): array {
152 $includes = rtrim($projectRoot, '/\\') . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR;
153 $paths = array();
154 foreach (glob($includes . 'diagnostics' . DIRECTORY_SEPARATOR . '*.php') ?: array() as $path) {
155 $paths[self::shortName($path)] = $path;
156 }
157 foreach (self::EXTERNAL_MODULES as $relative) {
158 $path = $includes . str_replace('/', DIRECTORY_SEPARATOR, $relative);
159 $paths[self::shortName($path)] = $path;
160 }
161 ksort($paths);
162 return $paths;
163 }
164
165 /** The record's key for one module: its basename without the .php suffix. */
166 public static function shortName(string $path): string {
167 return preg_replace('/\.php$/', '', basename($path)) ?? basename($path);
168 }
169
170 /**
171 * Content-addressed build ID embedded into the early boot modules.
172 *
173 * Marker literals are normalized before hashing so the ID can be embedded
174 * into the files it covers without becoming a self-referential hash. The
175 * release-consistency test recomputes this value and fails whenever a
176 * covered module changes without refreshing the compiled marker.
177 */
178 public static function releaseBuildId(): string {
179 return self::buildIdForPaths(self::modulePaths());
180 }
181
182 /**
183 * Compute the shipped manifest for another source root without loading or
184 * executing any PHP from that caller-supplied directory.
185 */
186 public static function releaseBuildIdForRoot(string $projectRoot): string {
187 return self::buildIdForPaths(self::baseModulePathsForRoot($projectRoot));
188 }
189
190 /** @param array<string, string> $paths */
191 private static function buildIdForPaths(array $paths): string {
192 $parts = array();
193 foreach ($paths as $name => $path) {
194 $parts[] = $name . ':' . self::canonicalSourceHash($path);
195 }
196 return sha1(implode('|', $parts));
197 }
198
199 /**
200 * Hash every module, reconcile each against the opcode cache, and return
201 * the compact manifest for request_start.
202 *
203 * @param ABJ_404_Solution_OpcacheGenerationProbe $opcache The request's single
204 * opcode-cache read. When it has no per-script data every module is
205 * reported as 'unknown' rather than silently as "not cached".
206 * @return array<string, mixed>
207 */
208 public static function capture(ABJ_404_Solution_OpcacheGenerationProbe $opcache): array {
209 $files = array();
210 $unreadable = array();
211 $uncached = array();
212 $stale = array();
213 $cached = 0;
214 $unknown = 0;
215 $parts = array(defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : 'unknown');
216 $releaseParts = array();
217
218 foreach (self::modulePaths() as $name => $path) {
219 $source = @file_get_contents($path);
220 $hash = is_string($source) ? md5($source) : false;
221 $mtime = @is_file($path) ? @filemtime($path) : false;
222 $releaseParts[] = $name . ':' . (
223 is_string($source) ? self::canonicalSourceHashOf($source) : 'missing'
224 );
225 if (!is_string($hash)) {
226 $unreadable[] = $name;
227 $parts[] = $name . ':missing';
228 continue;
229 }
230 $files[$name] = substr($hash, 0, self::SHORT_HASH_CHARS);
231 $parts[] = $name . ':' . $hash . ':' . (is_int($mtime) ? $mtime : '');
232
233 // A null 'cached' is "no per-script data", which is a different
234 // finding from "this module is not cached"; and a false
235 // 'matches_file' is direct proof that the executing opcodes and
236 // the file on disk are from different generations.
237 $state = $opcache->stateFor($path, is_int($mtime) ? $mtime : null);
238 if ($state['cached'] === null) {
239 $unknown++;
240 continue;
241 }
242 if ($state['cached'] === false) {
243 $uncached[] = $name;
244 continue;
245 }
246 $cached++;
247 if ($state['matches_file'] === false) {
248 $stale[] = $name;
249 }
250 }
251
252 $releaseBuildId = sha1(implode('|', $releaseParts));
253 $precomputedBuildId = defined('ABJ404_DIAGNOSTIC_BUILD_ID')
254 ? (string)ABJ404_DIAGNOSTIC_BUILD_ID : '';
255 return array(
256 'schema' => self::SCHEMA_VERSION,
257 'hash' => sha1(implode('|', $parts)),
258 'diagnostic_build_id' => $releaseBuildId,
259 'precomputed_build_id' => $precomputedBuildId,
260 'precomputed_build_matches_files' => $precomputedBuildId !== ''
261 ? hash_equals($releaseBuildId, $precomputedBuildId) : null,
262 'module_count' => count($files) + count($unreadable),
263 'unreadable' => $unreadable,
264 'opcache' => array(
265 'cached' => $cached,
266 'unknown' => $unknown,
267 'uncached' => $uncached,
268 'stale' => $stale,
269 ),
270 'files' => $files,
271 );
272 }
273
274 private static function canonicalSourceHash(string $path): string {
275 $source = @file_get_contents($path);
276 if (!is_string($source)) {
277 return 'missing';
278 }
279 return self::canonicalSourceHashOf($source);
280 }
281
282 private static function canonicalSourceHashOf(string $source): string {
283 $canonical = preg_replace(
284 array(
285 "/(ABJ404_DIAGNOSTIC_BUILD_ID'\\s*,\\s*')[0-9a-f]{40}(')/",
286 "/(const\\s+DIAGNOSTIC_BUILD_ID\\s*=\\s*')[0-9a-f]{40}(')/",
287 ),
288 '$1<diagnostic-build-id>$2',
289 $source
290 );
291 return sha1(is_string($canonical) ? $canonical : $source);
292 }
293 }
294