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 / feedback / CanonicalSuppressionSupportSection.php

CanonicalSuppressionSupportSection.php in 404 Solution trunk, at includes/feedback/CanonicalSuppressionSupportSection.php

174 lines 8.5 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 * The support-payload section carrying what stopped WordPress core from
9 * canonicalizing a URL on the reporting site.
10 *
11 * ABJ_404_Solution_CanonicalRedirectHookCensus owns taking that reading, on the
12 * front end, at the moment core's canonical redirect would have run. This class
13 * owns only the payload job of asking for it and rendering the answer inside a
14 * byte budget, which is the same contract every other *SupportSection here
15 * honours.
16 *
17 * The section is unconditional. A captured 404 whose canonicalization was
18 * suppressed is indistinguishable, in every other record, from one that was
19 * always broken, so "no census" and "census says the hook is fine" have to be
20 * different answers in the report. An omitted section reads as neither.
21 */
22 final class ABJ_404_Solution_CanonicalSuppressionSupportSection {
23
24 /**
25 * Hard cap on the rendered block. It is one reading plus a capped suspect
26 * roster, so it is small by construction; the cap is what keeps it small no
27 * matter how long a site's `template_redirect` chain is, and render() sheds
28 * the roster to fit rather than being cut mid-record. Reclaimed from the
29 * checkpoint excerpt budget so the section sum stays inside the report
30 * contract -- see
31 * ABJ_404_Solution_CheckpointJournalReader::MAX_SUPPORT_EXCERPT_BYTES and
32 * SupportExcerptBudgetContractTest.
33 *
34 * Sized to hold a FULL roster rather than merely to be small. A recorded
35 * entry runs about 90 bytes (`{"priority":20,"callback":"Some_Class::method",
36 * "origin":"plugin:some-plugin"}`), so
37 * ABJ_404_Solution_CanonicalRedirectHookCensus::MAX_CALLBACKS of them plus
38 * the reading around them needs roughly 2.7 KB. A 2 KB budget looked
39 * conservative and was in fact the worst possible choice: every site long
40 * enough to be interesting would have shed its whole roster, leaving the
41 * finding "something removed core canonicalization" with nothing named --
42 * which is the one thing this section exists to say.
43 */
44 const MAX_CANONICAL_SUPPRESSION_BYTES = 3072;
45
46 /**
47 * How many suspects survive the first shed.
48 *
49 * The roster is stored in dispatch order, so slicing from the front keeps
50 * the callbacks that run EARLIEST -- the ones with the opportunity to
51 * remove core's callback before it would have fired. Shedding to a short
52 * named list beats shedding to none: a reader with eight names has somewhere
53 * to start, and a reader with zero is back to writing to the site owner.
54 */
55 const REDUCED_CALLBACK_COUNT = 8;
56
57 /** The one JSON key the census hangs under, so a reader can grep for it. */
58 const CANONICAL_HOOK_CENSUS_KEY = 'abj404_canonical_hook_census';
59
60 /**
61 * The whole section, ready to join into the support payload.
62 *
63 * Guarded twice, because a support request is the last thing that may be
64 * blocked by its own diagnostics: a partially recovered install can be
65 * missing any plugin file (see the safe-autoloader work for error 18), and a
66 * read that throws must degrade to a stated reason rather than to a fatal in
67 * the request the admin is waiting on.
68 */
69 public static function compose(): string {
70 if (!class_exists('ABJ_404_Solution_CanonicalRedirectHookCensus')) {
71 return 'Canonical hook census unavailable [CANONICAL_CENSUS_CLASS_UNAVAILABLE]:'
72 . ' ABJ_404_Solution_CanonicalRedirectHookCensus could not be loaded on this install,'
73 . ' so whether core still canonicalizes on this site was not observed.'
74 . ' Reinstall the same plugin version, then reproduce the 404 before generating support data again.';
75 }
76 try {
77 return self::render(ABJ_404_Solution_CanonicalRedirectHookCensus::read());
78 } catch (Throwable $e) {
79 return 'Canonical hook census could not be read [CANONICAL_CENSUS_READ_FAILED]: '
80 . substr($e->getMessage(), 0, 200)
81 . '. Reproduce the 404 once, then generate support data again; include this code if it recurs.';
82 }
83 }
84
85 /**
86 * The census as a scannable header line plus one JSON record.
87 *
88 * Over-budget input sheds the suspect ROSTER -- the one reducible part --
89 * in two steps, keeping the earliest names before giving them up entirely,
90 * and only then falls back to the finding alone. Never cut at a byte offset:
91 * a record cut mid-JSON is unreadable by machine and misleading to a human,
92 * which is the same failure the drained client buffer already taught the
93 * composer (see SupportEvidenceExcerpt::appendClientTransportTelemetry).
94 *
95 * @param array<string, mixed> $record ABJ_404_Solution_CanonicalRedirectHookCensus::read().
96 */
97 private static function render(array $record): string {
98 if ($record === array()) {
99 return 'Canonical hook census -- not yet observed: this site has not served a front-end'
100 . ' 404 through this build, so whether WordPress core would still canonicalize was'
101 . " never read. Absent, not intact.\n";
102 }
103 $header = 'Canonical hook census -- ' . self::summary($record) . " (JSON):\n";
104 $roster = isset($record['callbacks']) && is_array($record['callbacks'])
105 ? $record['callbacks'] : array();
106 $shortened = $record;
107 $shortened['callbacks'] = array_slice($roster, 0, self::REDUCED_CALLBACK_COUNT);
108 $shortened['callbacks_reduced'] = 'earliest_' . self::REDUCED_CALLBACK_COUNT;
109 $reduced = $record;
110 $reduced['callbacks'] = array();
111 $reduced['callbacks_reduced'] = 'over_budget';
112 $minimal = array(
113 'core_canonical' => self::textOf($record, 'core_canonical'),
114 'suppression' => isset($record['suppression']) && is_array($record['suppression'])
115 ? $record['suppression'] : array(),
116 'core_canonical_priority' => $record['core_canonical_priority'] ?? null,
117 'plugin_listener_priority' => $record['plugin_listener_priority'] ?? null,
118 'callback_count' => self::countOf($record, 'callback_count'),
119 'recorded_at' => self::countOf($record, 'recorded_at'),
120 'reduced' => 'over_budget',
121 );
122 foreach (array($record, $shortened, $reduced, $minimal) as $candidate) {
123 $line = json_encode(array(self::CANONICAL_HOOK_CENSUS_KEY => $candidate));
124 if (is_string($line)
125 && strlen($header) + strlen($line) <= self::MAX_CANONICAL_SUPPRESSION_BYTES) {
126 return $header . $line;
127 }
128 }
129 return $header . 'The census record could not be encoded for this payload. JSON error ' .
130 (string)json_last_error() . ': ' . json_last_error_msg() .
131 '. Check callback names and origins for invalid UTF-8.';
132 }
133
134 /**
135 * The one-line version, so the first thing a reader sees is the finding and
136 * how much of the hook it was drawn from.
137 *
138 * "attached" with no suppression reasons and "attached" with the plugin
139 * running first are entirely different findings -- the second one names this
140 * plugin as the reason core never got its turn -- so the reasons are part of
141 * the summary rather than buried in the record.
142 *
143 * @param array<string, mixed> $record
144 */
145 private static function summary(array $record): string {
146 $reasons = isset($record['suppression']) && is_array($record['suppression'])
147 ? array_filter($record['suppression'], 'is_scalar') : array();
148 return 'core redirect_canonical ' . self::textOf($record, 'core_canonical') . '; '
149 . ($reasons === array() ? 'no suppression found' : 'suppression: ' . implode(', ', $reasons))
150 . '; ' . self::countOf($record, 'callback_count') . ' callback(s) on '
151 . self::textOf($record, 'hook') . '; observed at ' . self::countOf($record, 'recorded_at');
152 }
153
154 /**
155 * One record field as a string, or '' when it is absent or not scalar.
156 *
157 * @param array<string, mixed> $record
158 */
159 private static function textOf(array $record, string $field): string {
160 $value = $record[$field] ?? null;
161 return is_scalar($value) ? (string)$value : '';
162 }
163
164 /**
165 * One record field as an integer, or 0 when it is absent or not scalar.
166 *
167 * @param array<string, mixed> $record
168 */
169 private static function countOf(array $record, string $field): int {
170 $value = $record[$field] ?? null;
171 return is_scalar($value) ? (int)$value : 0;
172 }
173 }
174