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

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

250 lines 9.4 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 require_once __DIR__ . '/FeedbackTransportLog.php';
8
9 /**
10 * Validates outbound report payloads against the shared server JSON Schema.
11 *
12 * The payload is converted through JSON before validation so PHP associative
13 * arrays are checked as the object/list shapes the server actually receives.
14 */
15 class ABJ_404_Solution_ReportPayloadJsonSchemaValidator {
16
17 const REASON_VALIDATION_FAILED = 'contract_validation_failed';
18 const SCHEMA_RELATIVE_PATH = 'contracts/schemas/report.schema.json';
19 const OBJECT_FIELDS = array('resource_limits', 'extensions', 'environment_extras');
20
21 /**
22 * @param array<string, mixed> $payload
23 * @return array{valid: bool, reason: string, detail: string}
24 */
25 public static function validate(array $payload): array {
26 if (!self::ensureOpisLoaded()) {
27 // opis/json-schema is a dev/test-only dependency (declared in
28 // tests/composer.json, never bundled into the shipped plugin --
29 // vendoring a third-party Composer package into a WordPress
30 // plugin risks a global-namespace class collision with another
31 // active plugin bundling a different version of the same
32 // library). This is the expected state on every production
33 // site, not an error condition: this check is an optional
34 // fail-fast pre-flight, and the real source of truth is the
35 // live server's own schema validation, which the transport
36 // layer already falls back to email for on rejection. Treating
37 // "can't run the optional check" as "payload is invalid" used
38 // to throw out of FeedbackPayloadSchemaGuard::assert() and
39 // silently drop every error/heartbeat/uninstall/support_request
40 // report on any site without a coincidental Opis class
41 // collision -- see project memory
42 // project_opis_missing_breaks_telemetry.md for the incident.
43 return self::skipped();
44 }
45
46 $schema = self::loadSchema();
47 if (!$schema instanceof \stdClass) {
48 // Same reasoning as above: the schema file itself failing to
49 // load is an environment problem with the optional local
50 // pre-flight, not evidence the payload is malformed.
51 return self::skipped();
52 }
53
54 $data = self::payloadToJsonData(self::toWirePayload($payload));
55 if (!$data instanceof \stdClass) {
56 return self::skipped();
57 }
58
59 try {
60 $result = (new \Opis\JsonSchema\Validator())->validate($data, $schema);
61 } catch (\Throwable $e) {
62 // Unlike the "optional pre-flight unavailable" skips above (Opis
63 // missing, schema unreadable), this is the validator itself
64 // throwing during a call that should have succeeded -- the
65 // library was loaded and the schema/data both decoded fine.
66 // That is unexpected enough (e.g. a schema-authoring bug in
67 // report.schema.json) that it should be visible to a maintainer,
68 // not just silently treated as "can't check locally".
69 ABJ_404_Solution_FeedbackTransportLog::log('warn',
70 'ReportPayloadJsonSchemaValidator: Opis validator threw during validate(): ' . $e->getMessage());
71 return self::skipped();
72 }
73
74 if ($result->isValid()) {
75 return array('valid' => true, 'reason' => '', 'detail' => '');
76 }
77
78 // The validator ran successfully and found a real contract
79 // violation -- this is the one case that should still fail closed,
80 // since it is the producer-drift bug this check exists to catch
81 // (in CI/dev, where Opis is available via tests/composer.json).
82 return self::invalid(self::formatValidationError($result->error()));
83 }
84
85 /**
86 * Convert ambiguous PHP empty arrays to JSON objects for schema fields
87 * that the server declares as objects. Non-empty associative arrays
88 * already encode as JSON objects; non-empty lists are left untouched so
89 * validation still catches object-vs-list drift.
90 *
91 * @param array<string, mixed> $payload
92 * @return array<string, mixed>
93 */
94 public static function toWirePayload(array $payload): array {
95 foreach (self::OBJECT_FIELDS as $field) {
96 if (array_key_exists($field, $payload) && $payload[$field] === array()) {
97 $payload[$field] = (object)array();
98 }
99 }
100 return $payload;
101 }
102
103 private static function ensureOpisLoaded(): bool {
104 $available = class_exists('\Opis\JsonSchema\Validator') && class_exists('\Opis\JsonSchema\Errors\ErrorFormatter');
105
106 if (!$available) {
107 $autoload = dirname(__DIR__) . '/vendor/autoload.php';
108 if (file_exists($autoload)) {
109 require_once $autoload;
110 }
111 $available = class_exists('\Opis\JsonSchema\Validator') && class_exists('\Opis\JsonSchema\Errors\ErrorFormatter');
112 }
113
114 // Real extension point (a site could force this optional pre-flight
115 // off) that doubles as the test seam for simulating "Opis
116 // unavailable": once a PHP process has autoloaded Opis the classes
117 // stay defined for the rest of its lifetime, so a normal PHPUnit run
118 // (which loads tests/composer.json's Opis dependency at bootstrap)
119 // can never otherwise exercise this branch.
120 if (function_exists('apply_filters')) {
121 $available = (bool) apply_filters('abj404_report_schema_validator_available', $available);
122 }
123
124 return $available;
125 }
126
127 private static function loadSchema(): ?\stdClass {
128 static $schema = null;
129 static $loaded = false;
130
131 if ($loaded) {
132 return $schema instanceof \stdClass ? $schema : null;
133 }
134 $loaded = true;
135
136 $path = dirname(__DIR__, 2) . '/' . self::SCHEMA_RELATIVE_PATH;
137 $raw = file_exists($path) ? file_get_contents($path) : false;
138 if (!is_string($raw) || $raw === '') {
139 return null;
140 }
141
142 $decoded = json_decode($raw);
143 if (!$decoded instanceof \stdClass || json_last_error() !== JSON_ERROR_NONE) {
144 return null;
145 }
146
147 $schema = $decoded;
148 return $schema;
149 }
150
151 /**
152 * @param array<string, mixed> $payload
153 * @return \stdClass|null
154 */
155 private static function payloadToJsonData(array $payload): ?\stdClass {
156 $json = function_exists('wp_json_encode') ? wp_json_encode($payload) : json_encode($payload);
157 if (!is_string($json) || $json === '') {
158 return null;
159 }
160
161 $decoded = json_decode($json);
162 if (!$decoded instanceof \stdClass || json_last_error() !== JSON_ERROR_NONE) {
163 return null;
164 }
165
166 return $decoded;
167 }
168
169 private static function formatValidationError(?\Opis\JsonSchema\Errors\ValidationError $error): string {
170 if ($error === null) {
171 return self::SCHEMA_RELATIVE_PATH . ': unknown validation failure';
172 }
173
174 try {
175 $formatter = new \Opis\JsonSchema\Errors\ErrorFormatter();
176 $messages = self::flattenFormattedErrors($formatter->format($error));
177 if (empty($messages)) {
178 $messages = $formatter->formatFlat($error);
179 }
180 } catch (\Throwable $e) {
181 return self::SCHEMA_RELATIVE_PATH . ': could not format validation failure (' . $e->getMessage() . ')';
182 }
183
184 $rendered = array();
185 foreach ($messages as $message) {
186 if (is_scalar($message)) {
187 $rendered[] = (string)$message;
188 }
189 }
190
191 if (empty($rendered)) {
192 $rendered[] = 'unknown validation failure';
193 }
194
195 return self::SCHEMA_RELATIVE_PATH . ': ' . implode('; ', array_slice($rendered, 0, 8));
196 }
197
198 /**
199 * @param mixed $formatted
200 * @return array<int, string>
201 */
202 private static function flattenFormattedErrors($formatted): array {
203 $out = array();
204 if (!is_array($formatted)) {
205 return $out;
206 }
207
208 foreach ($formatted as $path => $messages) {
209 $label = (string)$path;
210 if (!is_array($messages)) {
211 if (is_scalar($messages)) {
212 $out[] = $label . ': ' . (string)$messages;
213 }
214 continue;
215 }
216
217 foreach ($messages as $message) {
218 if (is_scalar($message)) {
219 $out[] = $label . ': ' . (string)$message;
220 }
221 }
222 }
223
224 return $out;
225 }
226
227 /**
228 * @return array{valid: bool, reason: string, detail: string}
229 */
230 private static function invalid(string $detail): array {
231 return array(
232 'valid' => false,
233 'reason' => self::REASON_VALIDATION_FAILED,
234 'detail' => $detail,
235 );
236 }
237
238 /**
239 * The optional local pre-flight could not run (missing Opis, unreadable
240 * schema, encoding failure). Treated as valid -- not "the payload is
241 * bad", just "this site cannot double-check it locally" -- so the
242 * payload still reaches the real check on the server.
243 *
244 * @return array{valid: bool, reason: string, detail: string}
245 */
246 private static function skipped(): array {
247 return array('valid' => true, 'reason' => '', 'detail' => '');
248 }
249 }
250