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 / settings / StorageOptionContracts.php

StorageOptionContracts.php in 404 Solution trunk, at includes/settings/StorageOptionContracts.php

382 lines 13.3 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 * Versioned contracts for durable structured wp_options values.
9 *
10 * Reads are tolerant: legacy arrays are migrated to the current shape and
11 * corrupt values fall back to safe defaults after logging context. Writes are
12 * strict: invalid current payloads throw before update_option() can persist
13 * them.
14 */
15 class ABJ_404_Solution_StorageOptionContracts {
16
17 const CURRENT_VERSION = 2;
18 const OPTION_SETTINGS = 'abj404_settings';
19 const OPTION_UNINSTALL_PREFERENCES = 'abj404_uninstall_preferences';
20
21 /**
22 * @param string $optionName
23 * @param mixed $rawValue
24 * @return array<string, mixed>
25 */
26 public static function normalizeForRead(string $optionName, $rawValue): array {
27 if (!self::isContractedOption($optionName)) {
28 $value = self::toStringKeyedArray($rawValue);
29 return $value === null ? array() : $value;
30 }
31
32 if (!is_array($rawValue)) {
33 self::logReadIssue($optionName, 'expected array, got ' . gettype($rawValue));
34 return self::defaultValue($optionName);
35 }
36
37 $value = self::toStringKeyedArray($rawValue);
38 if ($value === null) {
39 self::logReadIssue($optionName, 'expected top-level string keys');
40 return self::defaultValue($optionName);
41 }
42
43 $version = self::readVersion($value);
44 if ($version === null) {
45 self::logReadIssue($optionName, 'invalid _schemaVersion value');
46 return self::defaultValue($optionName);
47 }
48
49 if ($version > self::CURRENT_VERSION) {
50 self::logReadIssue($optionName, 'future _schemaVersion ' . $version . ' is not supported');
51 return self::defaultValue($optionName);
52 }
53
54 try {
55 while ($version < self::CURRENT_VERSION) {
56 $migration = self::loadMigration($optionName, $version);
57 $migrated = $migration($value);
58 if (!is_array($migrated)) {
59 throw new RuntimeException('migration returned ' . gettype($migrated) . ', expected array');
60 }
61 $nextVersion = self::readVersion($migrated);
62 if ($nextVersion !== $version + 1) {
63 throw new RuntimeException('migration advanced from v' . $version . ' to invalid version ' . var_export($nextVersion, true));
64 }
65 $value = $migrated;
66 $version = $nextVersion;
67 }
68 } catch (Throwable $e) {
69 self::logReadIssue($optionName, 'migration failed: ' . $e->getMessage());
70 return self::defaultValue($optionName);
71 }
72
73 if ($optionName === self::OPTION_SETTINGS) {
74 $value = self::mergeSettingsDefaults($value);
75 }
76
77 $violations = self::validateCurrentValue($optionName, $value);
78 if (!empty($violations)) {
79 self::logReadIssue($optionName, 'current schema validation failed: ' . implode('; ', $violations));
80 return self::defaultValue($optionName);
81 }
82
83 return $value;
84 }
85
86 /**
87 * @param string $optionName
88 * @param array<string, mixed> $value
89 * @return array<string, mixed>
90 */
91 public static function prepareForWrite(string $optionName, array $value): array {
92 if (!self::isContractedOption($optionName)) {
93 return $value;
94 }
95
96 if ($optionName === self::OPTION_UNINSTALL_PREFERENCES) {
97 $value = array_merge(self::defaultUninstallPreferences(), $value);
98 }
99
100 $value['_schemaVersion'] = self::CURRENT_VERSION;
101
102 $violations = self::validateCurrentValue($optionName, $value);
103 if (!empty($violations)) {
104 throw new InvalidArgumentException(
105 'Invalid wp_options storage payload for ' . $optionName . ': ' . implode('; ', $violations)
106 );
107 }
108
109 return $value;
110 }
111
112 /**
113 * @param string $optionName
114 * @param array<string, mixed> $value
115 * @return array<int, string>
116 */
117 public static function validateCurrentValue(string $optionName, array $value): array {
118 if (!self::isContractedOption($optionName)) {
119 return array();
120 }
121
122 $violations = self::validateSchemaVersion($value);
123 if ($optionName === self::OPTION_SETTINGS) {
124 return array_merge($violations, self::validateSettings($value));
125 }
126 if ($optionName === self::OPTION_UNINSTALL_PREFERENCES) {
127 return array_merge($violations, self::validateUninstallPreferences($value));
128 }
129
130 return $violations;
131 }
132
133 /**
134 * @return array<string, mixed>
135 */
136 public static function defaultUninstallPreferences(): array {
137 return array(
138 '_schemaVersion' => self::CURRENT_VERSION,
139 'delete_redirects' => false,
140 'delete_logs' => false,
141 'delete_cache' => true,
142 'send_feedback' => false,
143 'uninstall_reason' => '',
144 'selected_issues' => '',
145 'followup_details' => '',
146 'feedback_details' => '',
147 'better_plugin_name' => '',
148 'other_reason_text' => '',
149 'feedback_email' => '',
150 'include_diagnostics' => false,
151 );
152 }
153
154 private static function isContractedOption(string $optionName): bool {
155 return $optionName === self::OPTION_SETTINGS || $optionName === self::OPTION_UNINSTALL_PREFERENCES;
156 }
157
158 /**
159 * @param mixed $value
160 * @return array<string, mixed>|null
161 */
162 private static function toStringKeyedArray($value): ?array {
163 if (!is_array($value)) {
164 return null;
165 }
166
167 $normalized = array();
168 foreach ($value as $key => $item) {
169 if (!is_string($key)) {
170 return null;
171 }
172 $normalized[$key] = $item;
173 }
174
175 return $normalized;
176 }
177
178 /**
179 * Missing versions are the original unversioned storage shape, treated as v1.
180 *
181 * @param array<string, mixed> $value
182 */
183 private static function readVersion(array $value): ?int {
184 if (!array_key_exists('_schemaVersion', $value)) {
185 return 1;
186 }
187
188 $version = $value['_schemaVersion'];
189 if (is_int($version)) {
190 return $version;
191 }
192 if (is_string($version) && preg_match('/^[0-9]+$/', $version)) {
193 return (int)$version;
194 }
195
196 return null;
197 }
198
199 /**
200 * @param string $optionName
201 * @param int $fromVersion
202 * @return callable(array<string, mixed>): array<string, mixed>
203 */
204 private static function loadMigration(string $optionName, int $fromVersion): callable {
205 $file = null;
206 if ($optionName === self::OPTION_SETTINGS && $fromVersion === 1) {
207 $file = __DIR__ . '/../storage-migrations/abj404-settings-v1-to-v2.php';
208 } else if ($optionName === self::OPTION_UNINSTALL_PREFERENCES && $fromVersion === 1) {
209 $file = __DIR__ . '/../storage-migrations/abj404-uninstall-preferences-v1-to-v2.php';
210 }
211
212 if (!is_string($file) || !file_exists($file)) {
213 throw new RuntimeException('missing migration for ' . $optionName . ' v' . $fromVersion . '-to-v' . ($fromVersion + 1));
214 }
215
216 $migration = require $file;
217 if (!is_callable($migration)) {
218 throw new RuntimeException('migration file is not callable: ' . $file);
219 }
220
221 return $migration;
222 }
223
224 /**
225 * @return array<string, mixed>
226 */
227 private static function defaultValue(string $optionName): array {
228 if ($optionName === self::OPTION_UNINSTALL_PREFERENCES) {
229 return self::defaultUninstallPreferences();
230 }
231
232 return self::mergeSettingsDefaults(array('_schemaVersion' => self::CURRENT_VERSION));
233 }
234
235 /**
236 * @param array<string, mixed> $value
237 * @return array<string, mixed>
238 */
239 private static function mergeSettingsDefaults(array $value): array {
240 $value = array_merge(self::defaultSettings(), $value);
241 $value['_schemaVersion'] = self::CURRENT_VERSION;
242 return $value;
243 }
244
245 /**
246 * Storage-contract defaults must stay standalone because this file is
247 * loaded by uninstall.php without the plugin autoloader.
248 *
249 * @return array<string, mixed>
250 */
251 private static function defaultSettings(): array {
252 return array(
253 '_schemaVersion' => self::CURRENT_VERSION,
254 'default_redirect' => '301',
255 'capture_404' => '1',
256 'DB_VERSION' => '0.0.0',
257 'admin_notification_frequency' => 'instant',
258 );
259 }
260
261 /**
262 * @param array<string, mixed> $value
263 * @return array<int, string>
264 */
265 private static function validateSchemaVersion(array $value): array {
266 if (!array_key_exists('_schemaVersion', $value)) {
267 return array('missing required field: _schemaVersion');
268 }
269 if (!is_int($value['_schemaVersion'])) {
270 return array('_schemaVersion must be integer');
271 }
272 if ($value['_schemaVersion'] !== self::CURRENT_VERSION) {
273 return array('_schemaVersion must be ' . self::CURRENT_VERSION);
274 }
275 return array();
276 }
277
278 /**
279 * @param array<string, mixed> $value
280 * @return array<int, string>
281 */
282 private static function validateSettings(array $value): array {
283 $violations = array();
284
285 foreach (array('default_redirect', 'capture_404', 'DB_VERSION', 'admin_notification_frequency') as $field) {
286 if (!array_key_exists($field, $value)) {
287 $violations[] = 'missing required field: ' . $field;
288 }
289 }
290
291 if (array_key_exists('default_redirect', $value)) {
292 if (!(is_string($value['default_redirect']) || is_int($value['default_redirect']))) {
293 $violations[] = 'default_redirect must be string or integer';
294 }
295 }
296
297 if (array_key_exists('capture_404', $value)) {
298 if (!(is_string($value['capture_404']) || is_int($value['capture_404']))) {
299 $violations[] = 'capture_404 must be string or integer';
300 }
301 }
302
303 if (array_key_exists('DB_VERSION', $value) && !is_string($value['DB_VERSION'])) {
304 $violations[] = 'DB_VERSION must be string';
305 }
306
307 if (array_key_exists('admin_notification_email', $value) && !is_string($value['admin_notification_email'])) {
308 $violations[] = 'admin_notification_email must be string';
309 }
310
311 if (array_key_exists('admin_notification_frequency', $value) && !is_string($value['admin_notification_frequency'])) {
312 $violations[] = 'admin_notification_frequency must be string';
313 }
314
315 if (array_key_exists('admin_notification_digest_limit', $value)) {
316 $limit = $value['admin_notification_digest_limit'];
317 if (!(is_int($limit) || is_string($limit))) {
318 $violations[] = 'admin_notification_digest_limit must be string or integer';
319 }
320 }
321
322 if (array_key_exists('dest404_behavior', $value) && !is_string($value['dest404_behavior'])) {
323 $violations[] = 'dest404_behavior must be string';
324 }
325
326 return $violations;
327 }
328
329 /**
330 * @param array<string, mixed> $value
331 * @return array<int, string>
332 */
333 private static function validateUninstallPreferences(array $value): array {
334 $violations = array();
335
336 $boolFields = array('delete_redirects', 'delete_logs', 'delete_cache', 'send_feedback', 'include_diagnostics');
337 foreach ($boolFields as $field) {
338 if (!array_key_exists($field, $value)) {
339 $violations[] = 'missing required field: ' . $field;
340 } else if (!is_bool($value[$field])) {
341 $violations[] = $field . ' must be boolean';
342 }
343 }
344
345 $stringFields = array(
346 'uninstall_reason',
347 'selected_issues',
348 'followup_details',
349 'feedback_details',
350 'better_plugin_name',
351 'other_reason_text',
352 'feedback_email',
353 );
354 foreach ($stringFields as $field) {
355 if (!array_key_exists($field, $value)) {
356 $violations[] = 'missing required field: ' . $field;
357 } else if (!is_string($value[$field])) {
358 $violations[] = $field . ' must be string';
359 }
360 }
361
362 return $violations;
363 }
364
365 /**
366 * Record a read-normalization fallback.
367 *
368 * Deliberately logs ONLY to the inert PHP error-log fallback, never the
369 * runtime logger or the service container. normalizeForRead() runs inside
370 * the settings-read path; the runtime logger reads settings to locate its
371 * own log file, so logging a read issue through it re-enters this read and
372 * recurses without bound (the 4.3.0 "broken sites after the latest update"
373 * OOM). Hard rule: normalized settings reads must never call runtime
374 * logging. See PluginLogicOptionsResolver::getRawSettingValue() and
375 * scripts/lint/lint-logging-settings-cycle.sh.
376 */
377 private static function logReadIssue(string $optionName, string $detail): void {
378 $message = 'Storage contract read fallback for ' . $optionName . ': ' . $detail;
379 abj404_logPhpFallback('service-resolution-fallback', $message);
380 }
381 }
382