PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / API / V2 / Integrity_Controller.php

Integrity_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/API/V2/Integrity_Controller.php

583 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync read surface.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use WCPOS\WooCommercePOS\Logger;
11 use WCPOS\WooCommercePOS\Sync\Api;
12 use WCPOS\WooCommercePOS\Sync\Collections;
13 use WCPOS\WooCommercePOS\Sync\Digest_Index;
14 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
15 use WCPOS\WooCommercePOS\Sync\Integrity_Digest;
16 use WCPOS\WooCommercePOS\Sync\Request_Int_Param;
17 use WP_Error;
18 use WP_REST_Controller;
19 use WP_REST_Request;
20 use WP_REST_Response;
21 use WP_REST_Server;
22
23 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
24
25 /**
26 * Hash-backed range-checksum scan — the missing experiment between
27 * range-checksum and revision-hash (see change-signal-matrix-2026-06-10.md):
28 * sql-bypass detection at GROUP BY prices instead of full-hydration prices.
29 *
30 * GET /integrity/scan compares, per id-range bucket and entirely in SQL,
31 * the BIT_XOR aggregate of CURRENT raw-row digests (class-integrity-digest
32 * canonical expression) against the BIT_XOR aggregate of STORED hook-time
33 * digests — one SQL pass per side. ?bucket=<n> drills into a single bucket
34 * and returns the mismatching ids.
35 *
36 * Every one of those questions is asked through {@see Digest_Index}: this
37 * controller validates the request, shapes the envelope and owns the
38 * rebuild-scheduling policy — it knows no table, column or JOIN.
39 */
40 final class Integrity_Controller extends WP_REST_Controller {
41 use Endpoint_Permissions;
42 use Request_Int_Param;
43
44 public const DEFAULT_BUCKET_SIZE = 1000;
45 public const DEFAULT_LIMIT_BUCKETS = 50;
46
47 /**
48 * Consecutive drill-downs a bucket may report stale stored digests before
49 * the rebuild is scheduled. Must stay ABOVE the client's own escalation
50 * threshold (DEFAULT_HYBRID_POLICY.escalateToRevisionHashAfter = 2) so the
51 * client has pulled the drifted ids before the stored side is re-baselined.
52 */
53 public const DRIFT_REBUILD_THRESHOLD = 3;
54
55 /** Per-bucket drift streaks; never autoloaded, read only on the drill-down path. */
56 public const DRIFT_STREAK_OPTION = 'wcpos_integrity_drift_streaks';
57
58 /** Hard cap on tracked buckets so the option cannot grow unbounded. */
59 public const DRIFT_STREAK_MAX_BUCKETS = 256;
60
61 private Integrity_Digest $digests;
62
63 private Digest_Index $index;
64
65 public function __construct( ?Integrity_Digest $digests = null, ?Digest_Index $index = null ) {
66 $this->digests = $digests ?? new Integrity_Digest();
67 $this->index = $index ?? new Digest_Index();
68 }
69
70 public function register_routes(): void {
71 register_rest_route(
72 Api::ROUTE_NAMESPACE,
73 '/integrity/scan',
74 array(
75 'methods' => WP_REST_Server::READABLE,
76 'callback' => array( $this, 'scan' ),
77 'permission_callback' => array( $this, 'permissions_check' ),
78 'args' => array(
79 'bucket_size' => array(
80 'default' => self::DEFAULT_BUCKET_SIZE,
81 'sanitize_callback' => 'absint',
82 ),
83 'after_id' => array(
84 'default' => 0,
85 'sanitize_callback' => 'absint',
86 ),
87 'limit_buckets' => array(
88 'default' => self::DEFAULT_LIMIT_BUCKETS,
89 'sanitize_callback' => 'absint',
90 ),
91 'bucket' => array( 'sanitize_callback' => 'absint' ),
92 'collection' => array(
93 'default' => 'products',
94 'sanitize_callback' => 'sanitize_key',
95 ),
96 'status' => array(
97 'sanitize_callback' => static fn ( $status ) => 'publish' === $status ? 'publish' : '',
98 ),
99 ),
100 )
101 );
102
103 register_rest_route(
104 Api::ROUTE_NAMESPACE,
105 '/integrity/rebuild',
106 array(
107 'methods' => WP_REST_Server::CREATABLE,
108 'callback' => array( $this, 'rebuild' ),
109 'permission_callback' => array( $this, 'admin_permissions_check' ),
110 )
111 );
112
113 // Leg-3 existence reconcile (ADR 0014 increment 5b): the authoritative LIVE {id, digest,
114 // object_type} for one bucket's id-range — the FULL current set, not the drift subset the scan
115 // drill-down returns. The client set-differences its manifest against this to prune stale records.
116 register_rest_route(
117 Api::ROUTE_NAMESPACE,
118 '/integrity/bucket',
119 array(
120 'methods' => WP_REST_Server::READABLE,
121 'callback' => array( $this, 'bucket_list' ),
122 'permission_callback' => array( $this, 'permissions_check' ),
123 'args' => array(
124 'bucket' => array(
125 'default' => 0,
126 'sanitize_callback' => 'absint',
127 ),
128 'bucket_size' => array(
129 'default' => self::DEFAULT_BUCKET_SIZE,
130 'sanitize_callback' => 'absint',
131 ),
132 // Which id-space to walk: 'products' (default — products+variations over wp_posts) or
133 // 'customers' (wp_users). Each collection has its own digest source + numeric id-space.
134 'collection' => array(
135 'default' => 'products',
136 'sanitize_callback' => 'sanitize_key',
137 ),
138 'status' => array(
139 'sanitize_callback' => static function ( $status ) {
140 return 'publish' === $status ? 'publish' : '';
141 },
142 ),
143 ),
144 )
145 );
146 }
147
148 /**
149 * Backfill pre-existing rows and repair stored digest drift.
150 */
151 public function rebuild( WP_REST_Request $request ): WP_REST_Response {
152 $started = microtime( true );
153 $result = $this->digests->rebuild();
154
155 return rest_ensure_response(
156 array(
157 'collection' => 'products',
158 'writes' => $result['writes'],
159 'orphans_deleted' => $result['orphans_deleted'],
160 'stored_total' => $result['stored_total'],
161 'meta' => array(
162 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
163 'rebuild_duration_ms' => $result['duration_ms'],
164 'supported' => true,
165 ),
166 )
167 );
168 }
169
170 /**
171 * The authoritative current {id, digest, object_type} for every live servable record whose id falls
172 * in bucket [bucket*size, (bucket+1)*size). Digests come from the SAME 64-bit formula the manifest
173 * stores ({@see Digest_Index}), so client and server compare apples-to-apples; object_type lets the
174 * client route each pull/prune to the right lane (products and variations share the wp_posts id space).
175 *
176 * @return WP_Error|WP_REST_Response WP_Error (400) when the requested collection is not a supported
177 * id-space bucket; a bucket listing otherwise.
178 */
179 public function bucket_list( WP_REST_Request $request ) {
180 $bucket = $this->int_param( $request, 'bucket', 0, 0, PHP_INT_MAX );
181 $bucket_size = $this->int_param( $request, 'bucket_size', self::DEFAULT_BUCKET_SIZE, 1, 10000 );
182 $collection = $request->get_param( 'collection' );
183 $collection = \is_string( $collection ) ? $collection : 'products';
184
185 // Fail closed (review finding 7): the bucket walk only understands the id-space OWNER collections
186 // (products — which also folds variations — customers, orders). An unsupported collection must NOT
187 // silently fall into the products id-space via the else branch below and mis-report another
188 // id-space as products. Reject with a 400 that names the offending collection.
189 $supported = array_keys( Collections::with( 'digest' ) );
190 if ( ! \in_array( $collection, $supported, true ) ) {
191 return new WP_Error(
192 'woocommerce_pos_sync_unsupported_bucket_collection',
193 \sprintf( 'integrity/bucket does not support the "%s" collection', $collection ),
194 array( 'status' => 400 )
195 );
196 }
197
198 $range_start = $bucket * $bucket_size;
199 $range_end = $range_start + $bucket_size;
200
201 // Each collection has its OWN digest source + id-space (ADR 0015): products/variations over
202 // wp_posts, customers over wp_users, orders over HPOS/CPT — the index picks the source from the
203 // collection name. Products carry the servable scoping (readable-catalog `status` + the
204 // POS-hidden `online_only` ids) so this authoritative list AGREES with the pull filter and the
205 // reconcile prunes anything toggled hidden after it was pulled (ADR 0014 WP-M5).
206 $ids = $this->index->bucket_listing(
207 $collection,
208 array(
209 'start' => $range_start,
210 'end' => $range_end,
211 ),
212 array( 'status' => (string) $request->get_param( 'status' ) )
213 );
214
215 return new WP_REST_Response(
216 array(
217 'collection' => $collection,
218 'bucket' => $bucket,
219 'bucket_size' => $bucket_size,
220 'range' => array(
221 'start' => $range_start,
222 'end' => $range_end,
223 ),
224 'ids' => $ids,
225 ),
226 200
227 );
228 }
229
230 /**
231 * GET /integrity/scan?bucket_size=&after_id=&limit_buckets=[&bucket=].
232 *
233 * Bucket aggregate: BIT_XOR over per-row CRC32 digests, deliberately
234 * instead of MD5(GROUP_CONCAT(... ORDER BY id)). XOR is commutative and
235 * associative, so the aggregate needs no ORDER BY and carries no
236 * group_concat_max_len truncation hazard (which the plain range-checksum
237 * endpoint must patch per session); its state is a constant-size integer
238 * regardless of bucket population. Collision properties are
239 * detection-grade, not cryptographic: a drifted row escapes only on a
240 * 2^-32 per-row CRC collision, and two simultaneous drifts in one bucket
241 * cancel only when their XOR deltas are exactly equal — and the
242 * drill-down re-verifies per id before anything is acted on. Bucket
243 * record counts are compared alongside, so add/delete imbalances that
244 * could cancel in XOR still flag.
245 */
246 public function scan( WP_REST_Request $request ) {
247 $started = microtime( true );
248 $bucket_size = $this->int_param( $request, 'bucket_size', self::DEFAULT_BUCKET_SIZE, 1, 10000 );
249 $bucket_raw = $request->get_param( 'bucket' );
250 $collection = $request->get_param( 'collection' );
251 $collection = \is_string( $collection ) ? $collection : 'products';
252
253 if ( ! \in_array( $collection, array_keys( Collections::with( 'digest' ) ), true ) ) {
254 return new WP_Error(
255 'woocommerce_pos_sync_unsupported_scan_collection',
256 \sprintf( 'integrity/scan does not support the "%s" collection', $collection ),
257 array( 'status' => 400 )
258 );
259 }
260
261 if ( null !== $bucket_raw && '' !== $bucket_raw ) {
262 if ( 'products' !== $collection ) {
263 return new WP_Error(
264 'woocommerce_pos_sync_unsupported_scan_drill_down_collection',
265 \sprintf( 'integrity/scan drill-down does not support the "%s" collection', $collection ),
266 array( 'status' => 400 )
267 );
268 }
269 $bucket = max( 0, (int) $bucket_raw );
270 if ( $this->maybe_schedule_digest_rebuild() ) {
271 return rest_ensure_response(
272 $this->envelope(
273 array(
274 'bucket_size' => $bucket_size,
275 'bucket' => $bucket,
276 ),
277 array(),
278 true,
279 $started,
280 'drill-down: per-id stored-vs-current digest mismatches in one bucket.',
281 true
282 )
283 );
284 }
285
286 return $this->drill_down( $bucket, $bucket_size, $started );
287 }
288
289 $after_id = max( 0, (int) ( $request->get_param( 'after_id' ) ?? 0 ) );
290 $limit_buckets = $this->int_param( $request, 'limit_buckets', self::DEFAULT_LIMIT_BUCKETS, 1, 1000 );
291
292 // after_id pagination: the checkpoint is the last id covered by the
293 // previous window, so the next window starts at the following
294 // bucket boundary. Both SQL sides share the same [start, end) id
295 // window, so they always agree on which buckets are in scope.
296 $first_bucket = $after_id > 0 ? ( (int) floor( $after_id / $bucket_size ) ) + 1 : 0;
297 $window_start = $first_bucket * $bucket_size;
298 $window_end = ( $first_bucket + $limit_buckets ) * $bucket_size;
299
300 if ( 'products' === $collection && $this->maybe_schedule_digest_rebuild() ) {
301 return rest_ensure_response(
302 $this->envelope(
303 array(
304 'bucket_size' => $bucket_size,
305 'after_id' => $window_end - 1,
306 ),
307 array(),
308 true,
309 $started,
310 'stored hook-time digests vs current raw-row digests, BIT_XOR(64-bit MD5-derived) per bucket; mismatch = content changed without hooks (or stored side not yet backfilled).',
311 true
312 )
313 );
314 }
315
316 $filters = array( 'status' => (string) $request->get_param( 'status' ) );
317 $ttl = (int) apply_filters( 'woocommerce_pos_integrity_scan_cache_ttl', 120 );
318 $key_parts = array( $collection, $bucket_size, $filters, $window_start, $window_end );
319 $cache_key = 'wcpos_integrity_scan_' . md5( (string) wp_json_encode( $key_parts ) );
320 $cached = 0 < $ttl ? get_transient( $cache_key ) : false;
321
322 // Both sides in one call: the stored hook-time aggregate and the current
323 // raw-row aggregate over the SAME id window, plus the max id completion is
324 // judged against (the larger of both sides, so orphaned stored digests past
325 // the last live post still get scanned).
326 $aggregates = false !== $cached ? $cached : $this->index->bucket_aggregates(
327 array(
328 'bucket_size' => $bucket_size,
329 'start' => $window_start,
330 'end' => $window_end,
331 ),
332 $collection,
333 $filters
334 );
335 if ( 0 < $ttl && false === $cached ) {
336 set_transient( $cache_key, $aggregates, $ttl );
337 }
338
339 return rest_ensure_response(
340 $this->envelope(
341 array(
342 'bucket_size' => $bucket_size,
343 'after_id' => $window_end - 1,
344 ),
345 $aggregates['buckets'],
346 $window_end > $aggregates['max_id'],
347 $started,
348 'stored hook-time digests vs current raw-row digests, BIT_XOR(64-bit MD5-derived) per bucket; mismatch = content changed without hooks (or stored side not yet backfilled).',
349 false,
350 $collection
351 )
352 );
353 }
354
355 /**
356 * ?bucket=<n>: per-id comparison inside one bucket. Three mismatch
357 * shapes: changed (both sides present, digests differ), missing_stored
358 * (live row never digested — created without hooks or pre-backfill),
359 * deleted (stored digest whose row is gone — hook-bypassing delete).
360 */
361 private function drill_down( int $bucket, int $bucket_size, float $started ) {
362 $range_start = $bucket * $bucket_size;
363 $range_end = $range_start + $bucket_size;
364
365 $rows = $this->index->bucket_drift(
366 array(
367 'start' => $range_start,
368 'end' => $range_end,
369 )
370 );
371
372 // Each drifted id carries its collection (the hash-checksum id-space holds
373 // BOTH products and variations) so the host pulls the right path instead of
374 // assuming 'products' (ADR 0005 — engine reads DriftedId.collection).
375 $changes = array();
376 foreach ( $rows as $row ) {
377 $collection = Collections::collection_for_object_type( (string) ( $row['object_type'] ?? '' ) );
378 if ( null === $collection ) {
379 // Fail closed (#421 increment 5): the digest SQL constrains
380 // object_type to this id-space's own types, so this cannot
381 // happen live — but an unknown value must never masquerade as
382 // 'products' and mis-route a pull.
383 Logger::log( \sprintf( 'WCPOS sync: dropped drifted-id row with unknown object_type "%s" (id %d)', (string) ( $row['object_type'] ?? '' ), (int) $row['id'] ) );
384
385 continue;
386 }
387 $changes[] = array(
388 'id' => (int) $row['id'],
389 'status' => (string) $row['status'],
390 'collection' => $collection,
391 // Unsigned 64-bit (ADR 0014 M1): keep as strings — a (int) cast (and JS Number) can't
392 // hold values above PHP_INT_MAX / 2^53 without precision loss.
393 'stored_digest' => null === $row['stored_digest'] ? null : (string) $row['stored_digest'],
394 'current_digest' => null === $row['current_digest'] ? null : (string) $row['current_digest'],
395 );
396 }
397
398 $this->maybe_schedule_stale_digest_rebuild( $bucket, $bucket_size, $changes );
399
400 return rest_ensure_response(
401 $this->envelope(
402 array(
403 'bucket_size' => $bucket_size,
404 'bucket' => $bucket,
405 ),
406 $changes,
407 true,
408 $started,
409 'drill-down: per-id stored-vs-current digest mismatches in one bucket.'
410 )
411 );
412 }
413
414 /**
415 * Schedule one guarded rebuild when product digests are empty or use an old formula.
416 */
417 private function maybe_schedule_digest_rebuild(): bool {
418 $needs_rebuild = $this->index->needs_product_rebuild();
419 $stored_fingerprint = (string) get_option( Digest_Index::FORMULA_FP_OPTION, '' );
420 if ( '' === $stored_fingerprint ) {
421 $stored_fingerprint = Digest_Index::legacy_formula_fingerprint();
422 update_option( Digest_Index::FORMULA_FP_OPTION, $stored_fingerprint, false );
423 }
424
425 if ( ! $needs_rebuild && Digest_Index::digest_formula_fingerprint() === $stored_fingerprint ) {
426 return false;
427 }
428
429 $this->save_drift_streaks( array() );
430 $this->schedule_guarded_rebuild();
431
432 return true;
433 }
434
435 /**
436 * Schedule the rebuild when a bucket keeps reporting the SAME stale stored
437 * digests drill-down after drill-down.
438 *
439 * A 'changed' row means the stored side has a digest that no longer matches
440 * the row — i.e. a write that bypassed the hooks (bulk import, WP-CLI,
441 * direct SQL, a migration plugin). Pulling cannot fix that: the client
442 * refreshes its own copy, but nothing rewrites the STORED digest, so the
443 * bucket mismatches forever and the till shows a permanent "records need
444 * attention" for data that is already correct. Observed on dev-pro
445 * 2026-08-19: 138 products drifted by a hookless bulk edit, re-escalated
446 * every sweep, local copies byte-identical to the server.
447 *
448 * Why a STREAK and not the first sight of drift: for a hookless write the
449 * integrity scan is the ONLY signal — such a write bypasses the sequence
450 * log too — so re-baselining on first detection would erase the one thing
451 * telling clients to re-pull. The client drills a mismatched bucket every
452 * sweep and issues targeted pulls each time; it escalates at 2 consecutive
453 * post-pull mismatches (DEFAULT_HYBRID_POLICY.escalateToRevisionHashAfter).
454 * Rebuilding at 3 therefore only ever fires AFTER the client has pulled and
455 * still sees the mismatch — the point at which the drift is provably the
456 * stored side's problem, not a delivery problem.
457 *
458 * @param int $bucket Bucket just drilled down.
459 * @param int $bucket_size Number of ids covered by the bucket.
460 * @param array $changes Rows the drill-down is returning.
461 */
462 private function maybe_schedule_stale_digest_rebuild( int $bucket, int $bucket_size, array $changes ): void {
463 $stale = 0;
464 foreach ( $changes as $change ) {
465 if ( 'changed' === ( $change['status'] ?? '' ) ) {
466 ++$stale;
467 }
468 }
469
470 $streaks = get_option( self::DRIFT_STREAK_OPTION, array() );
471 if ( ! \is_array( $streaks ) ) {
472 $streaks = array();
473 }
474
475 $streak_key = $bucket_size . ':' . $bucket;
476
477 if ( 0 === $stale ) {
478 // Reconciled (or only deletions/missing_stored left) — forget it.
479 if ( isset( $streaks[ $streak_key ] ) ) {
480 unset( $streaks[ $streak_key ] );
481 $this->save_drift_streaks( $streaks );
482 }
483
484 return;
485 }
486
487 $streak = ( (int) ( $streaks[ $streak_key ] ?? 0 ) ) + 1;
488
489 if ( $streak < self::DRIFT_REBUILD_THRESHOLD ) {
490 $streaks[ $streak_key ] = $streak;
491 $this->save_drift_streaks( $streaks );
492
493 return;
494 }
495
496 Logger::log(
497 \sprintf(
498 'WCPOS sync: bucket %d (size %d) reported %d stale stored digest(s) on %d consecutive drill-downs; scheduling an integrity digest rebuild.',
499 $bucket,
500 $bucket_size,
501 $stale,
502 $streak
503 )
504 );
505
506 // The rebuild re-digests the whole product space, so every bucket's
507 // streak is moot once it is queued.
508 $this->save_drift_streaks( array() );
509 $this->schedule_guarded_rebuild();
510 }
511
512 /**
513 * Persist the per-bucket streak map, bounded so a pathological catalogue
514 * cannot grow an unbounded option. Never autoloaded — it is read only on
515 * the drill-down path.
516 *
517 * @param array $streaks Bucket-size:bucket => consecutive drifted drill-downs.
518 */
519 private function save_drift_streaks( array $streaks ): void {
520 if ( \count( $streaks ) > self::DRIFT_STREAK_MAX_BUCKETS ) {
521 arsort( $streaks );
522 $streaks = \array_slice( $streaks, 0, self::DRIFT_STREAK_MAX_BUCKETS, true );
523 }
524
525 if ( array() === $streaks ) {
526 delete_option( self::DRIFT_STREAK_OPTION );
527
528 return;
529 }
530
531 update_option( self::DRIFT_STREAK_OPTION, $streaks, false );
532 }
533
534 /**
535 * Queue one rebuild behind the owner-token lease. Shared by both triggers.
536 */
537 private function schedule_guarded_rebuild(): void {
538 if ( false === get_transient( Integrity_Digest::REBUILD_LOCK ) ) {
539 // Owner-token lease: a rebuild outliving the TTL must not delete a
540 // SUCCESSOR's lock in its finally (the callback captures the token
541 // at start and only releases a matching lease).
542 $token = uniqid( 'wcpos_rebuild_', true );
543 set_transient( Integrity_Digest::REBUILD_LOCK, $token, Integrity_Digest::REBUILD_LOCK_TTL );
544 if ( wp_next_scheduled( Integrity_Digest::REBUILD_HOOK ) ) {
545 // An identical event is already queued (e.g. a concurrent scan
546 // won the race, or a prior lock expired before cron fired):
547 // keep the fresh lease and do not stack another event.
548 return;
549 }
550 if ( false === wp_schedule_single_event( time(), Integrity_Digest::REBUILD_HOOK ) ) {
551 if ( get_transient( Integrity_Digest::REBUILD_LOCK ) === $token ) {
552 delete_transient( Integrity_Digest::REBUILD_LOCK );
553 }
554 Logger::error( 'WCPOS sync: failed to schedule integrity digest rebuild.' );
555 }
556 }
557 }
558
559 /**
560 * Same envelope shape as class-changes-controller.php so the matrix client plumbing stays uniform.
561 */
562 private function envelope( array $checkpoint, array $changes, bool $complete, float $started, ?string $note = null, bool $rebuilding = false, string $collection = 'products' ): array {
563 $meta = array(
564 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
565 'supported' => true,
566 );
567 if ( null !== $note ) {
568 $meta['note'] = $note;
569 }
570 if ( $rebuilding ) {
571 $meta['rebuilding'] = true;
572 }
573
574 return array(
575 'collection' => $collection,
576 'checkpoint' => $checkpoint,
577 'changes' => $changes,
578 'complete' => $complete,
579 'meta' => $meta,
580 );
581 }
582 }
583