PluginProbe
Booking Calendar / 11.8
Booking Calendar v11.8
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / booking_modes_v3 / class-wpbc-booking-modes-v3-definition-validator.php

class-wpbc-booking-modes-v3-definition-validator.php in Booking Calendar 11.8, at includes/booking_modes_v3/class-wpbc-booking-modes-v3-definition-validator.php

594 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Strict validator for Booking Modes V3 declarations.
4 *
5 * @package Booking Calendar
6 * @since 11.8.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Validate and normalize the bounded, executable-free V3 declaration format.
15 */
16 final class WPBC_Booking_Modes_V3_Definition_Validator {
17
18 const MAX_COLLECTION_ENTRIES = 256;
19 const MAX_SIDEBAR_NODES = 512;
20 const MAX_SIDEBAR_DEPTH = 8;
21 const MAX_TEXT_BYTES = 4096;
22 const MAX_IDENTIFIER_BYTES = 128;
23 const MAX_ICON_BYTES = 512;
24 const MAX_DIAGNOSTICS = 50;
25
26 /** @var array<int,string> */
27 private $errors = array();
28
29 /** @var int */
30 private $sidebar_node_count = 0;
31
32 /** @var array<string,string> */
33 private $sidebar_placements = array();
34
35 /**
36 * Validate one allow-listed declaration.
37 *
38 * @param string $expected_mode_id Expected allow-list identifier.
39 * @param mixed $definition Included declaration value.
40 *
41 * @return array<string,mixed> Validation result with valid, definition, and errors keys.
42 */
43 public function validate( $expected_mode_id, $definition ) {
44 $this->errors = array();
45 $this->sidebar_node_count = 0;
46 $this->sidebar_placements = array();
47 $normalized = array();
48 $top_level_keys = array( 'id', 'label', 'description', 'default_route', 'quickstart_id', 'pages', 'wordpress_menu', 'sidebar_menu' );
49
50 if ( ! is_array( $definition ) ) {
51 $this->add_error( 'mode', 'The declaration must return an array.' );
52 return $this->result( $normalized );
53 }
54
55 $this->validate_exact_keys( 'mode', $definition, $top_level_keys, $top_level_keys );
56 $normalized['id'] = $this->validate_identifier( 'mode.id', isset( $definition['id'] ) ? $definition['id'] : null, false );
57 $normalized['label'] = $this->validate_text( 'mode.label', isset( $definition['label'] ) ? $definition['label'] : null, self::MAX_TEXT_BYTES );
58 $normalized['description'] = $this->validate_text( 'mode.description', isset( $definition['description'] ) ? $definition['description'] : null, self::MAX_TEXT_BYTES );
59 $normalized['default_route'] = $this->validate_route( 'mode.default_route', isset( $definition['default_route'] ) ? $definition['default_route'] : null );
60 $normalized['quickstart_id'] = $this->validate_identifier( 'mode.quickstart_id', isset( $definition['quickstart_id'] ) ? $definition['quickstart_id'] : null, true );
61
62 if ( $normalized['id'] !== $expected_mode_id ) {
63 $this->add_error( 'mode.id', 'The mode identifier does not match its allow-list key.' );
64 }
65
66 $normalized['pages'] = $this->validate_pages( isset( $definition['pages'] ) ? $definition['pages'] : null );
67 $normalized['wordpress_menu'] = $this->validate_wordpress_menu( isset( $definition['wordpress_menu'] ) ? $definition['wordpress_menu'] : null, $normalized['pages'] );
68 $normalized['sidebar_menu'] = $this->validate_sidebar_menu( isset( $definition['sidebar_menu'] ) ? $definition['sidebar_menu'] : null, $normalized['pages'] );
69 $this->validate_horizontal_hosts( $normalized['pages'] );
70
71 return $this->result( $normalized );
72 }
73
74 /**
75 * Build a stable validation result.
76 *
77 * @param array $definition Normalized declaration.
78 *
79 * @return array<string,mixed> Validation result.
80 */
81 private function result( $definition ) {
82 return array(
83 'valid' => empty( $this->errors ),
84 'definition' => empty( $this->errors ) ? $definition : array(),
85 'errors' => $this->errors,
86 );
87 }
88
89 /**
90 * Validate the pages map and its surface-local options.
91 *
92 * @param mixed $pages Raw pages map.
93 *
94 * @return array<string,array<string,mixed>> Normalized pages.
95 */
96 private function validate_pages( $pages ) {
97 $normalized_pages = array();
98
99 if ( ! $this->validate_map( 'mode.pages', $pages ) ) {
100 return $normalized_pages;
101 }
102
103 foreach ( $pages as $page_id => $page_definition ) {
104 $page_path = 'mode.pages.' . $page_id;
105 $this->validate_identifier( $page_path . ' key', $page_id, false );
106
107 if ( ! is_array( $page_definition ) ) {
108 $this->add_error( $page_path, 'The page definition must be an array.' );
109 continue;
110 }
111
112 $this->validate_exact_keys( $page_path, $page_definition, array( 'route', 'page_options', 'horizontal_menu' ), array( 'route' ) );
113 $normalized_page = array(
114 'route' => $this->validate_route( $page_path . '.route', isset( $page_definition['route'] ) ? $page_definition['route'] : null ),
115 );
116
117 if ( array_key_exists( 'page_options', $page_definition ) ) {
118 $normalized_page['page_options'] = $this->validate_page_options( $page_path . '.page_options', $page_definition['page_options'] );
119 }
120 if ( array_key_exists( 'horizontal_menu', $page_definition ) ) {
121 $normalized_page['horizontal_menu'] = $this->validate_horizontal_menu( $page_path . '.horizontal_menu', $page_definition['horizontal_menu'] );
122 }
123
124 $normalized_pages[ $page_id ] = $normalized_page;
125 }
126
127 return $normalized_pages;
128 }
129
130 /**
131 * Validate page-heading and host presentation options.
132 *
133 * @param string $path Diagnostic path.
134 * @param mixed $options Raw page options.
135 *
136 * @return array<string,mixed> Normalized options.
137 */
138 private function validate_page_options( $path, $options ) {
139 $normalized = array();
140 $string_keys = array( 'page_title', 'page_description', 'top_path_title' );
141 $bool_keys = array( 'is_show_top_path', 'is_show_top_navigation', 'top_navigation_use_subtabs' );
142
143 if ( ! is_array( $options ) ) {
144 $this->add_error( $path, 'Page options must be an array.' );
145 return $normalized;
146 }
147
148 $this->validate_exact_keys( $path, $options, array_merge( $string_keys, $bool_keys ), array() );
149 foreach ( $string_keys as $key ) {
150 if ( array_key_exists( $key, $options ) ) {
151 $normalized[ $key ] = $this->validate_text( $path . '.' . $key, $options[ $key ], self::MAX_TEXT_BYTES );
152 }
153 }
154 foreach ( $bool_keys as $key ) {
155 if ( array_key_exists( $key, $options ) ) {
156 $normalized[ $key ] = $this->validate_boolean( $path . '.' . $key, $options[ $key ] );
157 }
158 }
159
160 return $normalized;
161 }
162
163 /**
164 * Validate one horizontal-menu declaration.
165 *
166 * @param string $path Diagnostic path.
167 * @param mixed $definition Raw horizontal definition.
168 *
169 * @return array<string,mixed> Normalized definition.
170 */
171 private function validate_horizontal_menu( $path, $definition ) {
172 $normalized = array();
173
174 if ( ! is_array( $definition ) ) {
175 $this->add_error( $path, 'Horizontal menu options must be an array.' );
176 return $normalized;
177 }
178
179 $this->validate_exact_keys( $path, $definition, array( 'visible', 'title', 'font_icon', 'editions', 'show_on_pages' ), array() );
180 if ( array_key_exists( 'visible', $definition ) ) {
181 $normalized['visible'] = $this->validate_boolean( $path . '.visible', $definition['visible'] );
182 }
183 if ( array_key_exists( 'title', $definition ) ) {
184 $normalized['title'] = $this->validate_text( $path . '.title', $definition['title'], self::MAX_TEXT_BYTES );
185 }
186 if ( array_key_exists( 'font_icon', $definition ) ) {
187 $normalized['font_icon'] = $this->validate_text( $path . '.font_icon', $definition['font_icon'], self::MAX_ICON_BYTES );
188 }
189 if ( array_key_exists( 'editions', $definition ) ) {
190 $normalized['editions'] = $this->validate_editions( $path . '.editions', $definition['editions'] );
191 }
192 if ( array_key_exists( 'show_on_pages', $definition ) ) {
193 $normalized['show_on_pages'] = $this->validate_identifier_list( $path . '.show_on_pages', $definition['show_on_pages'] );
194 }
195
196 return $normalized;
197 }
198
199 /**
200 * Validate the native WordPress submenu overrides.
201 *
202 * @param mixed $menu Raw native menu map.
203 * @param array $pages Validated local pages.
204 *
205 * @return array<string,array<string,mixed>> Normalized native overrides.
206 */
207 private function validate_wordpress_menu( $menu, $pages ) {
208 $normalized = array();
209
210 if ( ! $this->validate_map( 'mode.wordpress_menu', $menu ) ) {
211 return $normalized;
212 }
213
214 foreach ( $menu as $menu_slug => $definition ) {
215 $path = 'mode.wordpress_menu.' . $menu_slug;
216 $this->validate_identifier( $path . ' key', $menu_slug, false );
217 if ( ! is_array( $definition ) ) {
218 $this->add_error( $path, 'A WordPress menu override must be an array.' );
219 continue;
220 }
221
222 $this->validate_exact_keys( $path, $definition, array( 'page_id', 'title', 'visible', 'editions' ), array() );
223 $normalized[ $menu_slug ] = array();
224 if ( array_key_exists( 'page_id', $definition ) ) {
225 $page_id = $this->validate_identifier( $path . '.page_id', $definition['page_id'], false );
226 if ( ! isset( $pages[ $page_id ] ) ) {
227 $this->add_error( $path . '.page_id', 'The local page reference is undefined.' );
228 }
229 $normalized[ $menu_slug ]['page_id'] = $page_id;
230 }
231 if ( array_key_exists( 'title', $definition ) ) {
232 $normalized[ $menu_slug ]['title'] = $this->validate_text( $path . '.title', $definition['title'], self::MAX_TEXT_BYTES );
233 }
234 if ( array_key_exists( 'visible', $definition ) ) {
235 $normalized[ $menu_slug ]['visible'] = $this->validate_boolean( $path . '.visible', $definition['visible'] );
236 }
237 if ( array_key_exists( 'editions', $definition ) ) {
238 $normalized[ $menu_slug ]['editions'] = $this->validate_editions( $path . '.editions', $definition['editions'] );
239 }
240 }
241
242 return $normalized;
243 }
244
245 /**
246 * Validate the recursive sidebar map.
247 *
248 * @param mixed $menu Raw sidebar map.
249 * @param array $pages Validated local pages.
250 *
251 * @return array<string,array<string,mixed>> Normalized sidebar map.
252 */
253 private function validate_sidebar_menu( $menu, $pages ) {
254 if ( ! $this->validate_map( 'mode.sidebar_menu', $menu ) ) {
255 return array();
256 }
257
258 return $this->validate_sidebar_nodes( 'mode.sidebar_menu', $menu, $pages, 1 );
259 }
260
261 /**
262 * Recursively validate sidebar nodes with bounded depth and node count.
263 *
264 * @param string $path Diagnostic path.
265 * @param array $nodes Raw node map.
266 * @param array $pages Validated local pages.
267 * @param int $depth Current one-based depth.
268 *
269 * @return array<string,array<string,mixed>> Normalized nodes.
270 */
271 private function validate_sidebar_nodes( $path, $nodes, $pages, $depth ) {
272 $normalized = array();
273
274 if ( $depth > self::MAX_SIDEBAR_DEPTH ) {
275 $this->add_error( $path, 'The sidebar nesting depth exceeds the supported limit.' );
276 return $normalized;
277 }
278
279 foreach ( $nodes as $node_key => $definition ) {
280 ++$this->sidebar_node_count;
281 $node_path = $path . '.' . $node_key;
282 $this->validate_identifier( $node_path . ' key', $node_key, false );
283 if ( $this->sidebar_node_count > self::MAX_SIDEBAR_NODES ) {
284 $this->add_error( $node_path, 'The sidebar node count exceeds the supported limit.' );
285 break;
286 }
287 if ( ! is_array( $definition ) ) {
288 $this->add_error( $node_path, 'A sidebar node must be an array.' );
289 continue;
290 }
291
292 $this->validate_exact_keys( $node_path, $definition, array( 'page_id', 'title', 'font_icon', 'font_icon_right', 'visible', 'editions', 'expanded', 'items' ), array() );
293 $normalized_node = array();
294 if ( array_key_exists( 'page_id', $definition ) ) {
295 $page_id = $this->validate_identifier( $node_path . '.page_id', $definition['page_id'], false );
296 if ( ! isset( $pages[ $page_id ] ) ) {
297 $this->add_error( $node_path . '.page_id', 'The local page reference is undefined.' );
298 }
299 $source_identity = isset( $pages[ $page_id ]['route'] )
300 ? WPBC_Booking_Modes_V3_Definition_Validator::get_route_identity( $pages[ $page_id ]['route'] )
301 : 'undefined:' . $page_id;
302 if ( isset( $this->sidebar_placements[ $source_identity ] ) ) {
303 $this->add_error( $node_path . '.page_id', 'The source page already has an explicit sidebar placement.' );
304 } else {
305 $this->sidebar_placements[ $source_identity ] = $node_path;
306 }
307 $normalized_node['page_id'] = $page_id;
308 }
309 foreach ( array( 'title', 'font_icon', 'font_icon_right' ) as $text_key ) {
310 if ( array_key_exists( $text_key, $definition ) ) {
311 $limit = 'title' === $text_key ? self::MAX_TEXT_BYTES : self::MAX_ICON_BYTES;
312 $normalized_node[ $text_key ] = $this->validate_text( $node_path . '.' . $text_key, $definition[ $text_key ], $limit );
313 }
314 }
315 if ( array_key_exists( 'visible', $definition ) ) {
316 $normalized_node['visible'] = $this->validate_boolean( $node_path . '.visible', $definition['visible'] );
317 }
318 if ( array_key_exists( 'editions', $definition ) ) {
319 $normalized_node['editions'] = $this->validate_editions( $node_path . '.editions', $definition['editions'] );
320 }
321 if ( array_key_exists( 'expanded', $definition ) ) {
322 $expanded = $definition['expanded'];
323 if ( ! is_string( $expanded ) || ! in_array( $expanded, array( 'On', 'Off', 'when_active' ), true ) ) {
324 $this->add_error( $node_path . '.expanded', 'Expanded must be On, Off, or when_active.' );
325 $expanded = '';
326 }
327 $normalized_node['expanded'] = $expanded;
328 }
329 if ( array_key_exists( 'items', $definition ) ) {
330 if ( $this->validate_map( $node_path . '.items', $definition['items'] ) ) {
331 $normalized_node['items'] = $this->validate_sidebar_nodes( $node_path . '.items', $definition['items'], $pages, $depth + 1 );
332 } else {
333 $normalized_node['items'] = array();
334 }
335 } elseif ( $depth > 1 && ! isset( $normalized_node['page_id'] ) ) {
336 $this->add_error( $node_path, 'A new nested folder must declare its items map.' );
337 }
338 if ( $depth > 1 && ! isset( $normalized_node['page_id'] ) && ( ! array_key_exists( 'title', $normalized_node ) || '' === $normalized_node['title'] ) ) {
339 $this->add_error( $node_path, 'A new nested folder must declare its title.' );
340 }
341
342 $normalized[ $node_key ] = $normalized_node;
343 }
344
345 return $normalized;
346 }
347
348 /**
349 * Validate local page references used as horizontal hosts.
350 *
351 * @param array $pages Validated pages map.
352 *
353 * @return void
354 */
355 private function validate_horizontal_hosts( $pages ) {
356 foreach ( $pages as $page_id => $page_definition ) {
357 if ( ! isset( $page_definition['horizontal_menu']['show_on_pages'] ) ) {
358 continue;
359 }
360 foreach ( $page_definition['horizontal_menu']['show_on_pages'] as $host_page_id ) {
361 if ( ! isset( $pages[ $host_page_id ] ) ) {
362 $this->add_error( 'mode.pages.' . $page_id . '.horizontal_menu.show_on_pages', 'The horizontal host page reference is undefined: ' . $host_page_id . '.' );
363 }
364 }
365 }
366 }
367
368 /**
369 * Validate a route object.
370 *
371 * @param string $path Diagnostic path.
372 * @param mixed $route Raw route.
373 *
374 * @return array<string,string> Normalized route.
375 */
376 private function validate_route( $path, $route ) {
377 $normalized = array( 'page' => '', 'tab' => '', 'subtab' => '' );
378
379 if ( ! is_array( $route ) ) {
380 $this->add_error( $path, 'A route must be an array.' );
381 return $normalized;
382 }
383
384 $this->validate_exact_keys( $path, $route, array( 'page', 'tab', 'subtab' ), array( 'page', 'tab' ) );
385 $normalized['page'] = $this->validate_identifier( $path . '.page', isset( $route['page'] ) ? $route['page'] : null, false );
386 $normalized['tab'] = $this->validate_identifier( $path . '.tab', isset( $route['tab'] ) ? $route['tab'] : null, false );
387 if ( array_key_exists( 'subtab', $route ) ) {
388 $normalized['subtab'] = $this->validate_identifier( $path . '.subtab', $route['subtab'], true );
389 }
390
391 return $normalized;
392 }
393
394 /**
395 * Build a stable identity for duplicate placement validation.
396 *
397 * @param array $route Normalized route.
398 *
399 * @return string Collision-safe source route identity.
400 */
401 private static function get_route_identity( $route ) {
402 return strlen( $route['page'] ) . ':' . $route['page'] . '|'
403 . strlen( $route['tab'] ) . ':' . $route['tab'] . '|'
404 . strlen( $route['subtab'] ) . ':' . $route['subtab'];
405 }
406
407 /**
408 * Validate an exact-edition list.
409 *
410 * @param string $path Diagnostic path.
411 * @param mixed $editions Raw edition list.
412 *
413 * @return array<int,string> Normalized editions.
414 */
415 private function validate_editions( $path, $editions ) {
416 $allowed = array( 'free', 'personal', 'business_small', 'business_medium', 'business_large', 'multiuser' );
417 $values = $this->validate_identifier_list( $path, $editions );
418
419 foreach ( $values as $edition_id ) {
420 if ( ! in_array( $edition_id, $allowed, true ) ) {
421 $this->add_error( $path, 'The edition identifier is not supported: ' . $edition_id . '.' );
422 }
423 }
424
425 return $values;
426 }
427
428 /**
429 * Validate an ordered list of unique identifiers.
430 *
431 * @param string $path Diagnostic path.
432 * @param mixed $values Raw list.
433 *
434 * @return array<int,string> Normalized list.
435 */
436 private function validate_identifier_list( $path, $values ) {
437 $normalized = array();
438
439 if ( ! is_array( $values ) || ! $this->is_list( $values ) ) {
440 $this->add_error( $path, 'The value must be an ordered list.' );
441 return $normalized;
442 }
443 if ( count( $values ) > self::MAX_COLLECTION_ENTRIES ) {
444 $this->add_error( $path, 'The list exceeds the supported entry limit.' );
445 return $normalized;
446 }
447
448 foreach ( $values as $index => $identifier ) {
449 $identifier = $this->validate_identifier( $path . '.' . $index, $identifier, false );
450 if ( in_array( $identifier, $normalized, true ) ) {
451 $this->add_error( $path . '.' . $index, 'Duplicate list values are not allowed.' );
452 }
453 $normalized[] = $identifier;
454 }
455
456 return $normalized;
457 }
458
459 /**
460 * Validate an associative map and its budget.
461 *
462 * @param string $path Diagnostic path.
463 * @param mixed $value Candidate map.
464 *
465 * @return bool True when the value can be iterated as a bounded map.
466 */
467 private function validate_map( $path, $value ) {
468 if ( ! is_array( $value ) ) {
469 $this->add_error( $path, 'The value must be an array map.' );
470 return false;
471 }
472 if ( count( $value ) > self::MAX_COLLECTION_ENTRIES ) {
473 $this->add_error( $path, 'The map exceeds the supported entry limit.' );
474 return false;
475 }
476
477 return true;
478 }
479
480 /**
481 * Validate required and allow-listed array keys.
482 *
483 * @param string $path Diagnostic path.
484 * @param array $value Candidate object-like array.
485 * @param array $allowed_keys Allowed string keys.
486 * @param array $required_keys Required string keys.
487 *
488 * @return void
489 */
490 private function validate_exact_keys( $path, $value, $allowed_keys, $required_keys ) {
491 foreach ( $required_keys as $required_key ) {
492 if ( ! array_key_exists( $required_key, $value ) ) {
493 $this->add_error( $path . '.' . $required_key, 'The required field is missing.' );
494 }
495 }
496 foreach ( array_keys( $value ) as $key ) {
497 if ( ! is_string( $key ) || ! in_array( $key, $allowed_keys, true ) ) {
498 $this->add_error( $path . '.' . ( is_scalar( $key ) ? (string) $key : '?' ), 'The field is not supported.' );
499 }
500 }
501 }
502
503 /**
504 * Validate a declaration identifier without coercion.
505 *
506 * @param string $path Diagnostic path.
507 * @param mixed $value Candidate identifier.
508 * @param bool $allow_empty Whether an empty identifier is valid.
509 *
510 * @return string Original valid identifier, or an empty string.
511 */
512 private function validate_identifier( $path, $value, $allow_empty ) {
513 if ( ! is_string( $value ) ) {
514 $this->add_error( $path, 'The identifier must be a string.' );
515 return '';
516 }
517 if ( '' === $value && $allow_empty ) {
518 return '';
519 }
520 if ( '' === $value || strlen( $value ) > self::MAX_IDENTIFIER_BYTES || ! preg_match( '/^[a-z0-9][a-z0-9_-]*$/', $value ) ) {
521 $this->add_error( $path, 'The identifier is empty, malformed, or too long.' );
522 return '';
523 }
524
525 return $value;
526 }
527
528 /**
529 * Validate declaration display text without changing explicit empty strings.
530 *
531 * @param string $path Diagnostic path.
532 * @param mixed $value Candidate text.
533 * @param int $max_bytes Maximum byte length.
534 *
535 * @return string Original valid text, or an empty string.
536 */
537 private function validate_text( $path, $value, $max_bytes ) {
538 if ( ! is_string( $value ) ) {
539 $this->add_error( $path, 'The value must be a string.' );
540 return '';
541 }
542 if ( strlen( $value ) > $max_bytes ) {
543 $this->add_error( $path, 'The string exceeds the supported byte limit.' );
544 return '';
545 }
546
547 return $value;
548 }
549
550 /**
551 * Validate a strict boolean without truthy coercion.
552 *
553 * @param string $path Diagnostic path.
554 * @param mixed $value Candidate boolean.
555 *
556 * @return bool Original boolean, or false after an error.
557 */
558 private function validate_boolean( $path, $value ) {
559 if ( ! is_bool( $value ) ) {
560 $this->add_error( $path, 'The value must be a boolean.' );
561 return false;
562 }
563
564 return $value;
565 }
566
567 /**
568 * Determine whether an array has sequential zero-based integer keys.
569 *
570 * @param array $value Candidate list.
571 *
572 * @return bool True for an ordered list, including an empty list.
573 */
574 private function is_list( $value ) {
575 return array_keys( $value ) === range( 0, count( $value ) - 1 ) || empty( $value );
576 }
577
578 /**
579 * Add one bounded diagnostic with its declaration path.
580 *
581 * @param string $path Declaration field path.
582 * @param string $message Human-readable failure reason.
583 *
584 * @return void
585 */
586 private function add_error( $path, $message ) {
587 if ( count( $this->errors ) >= self::MAX_DIAGNOSTICS ) {
588 return;
589 }
590
591 $this->errors[] = $path . ': ' . $message;
592 }
593 }
594