PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.8
ZIP AI – AI Website Builder & AI Agent v0.0.8
0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / snippet-store.php

snippet-store.php in ZIP AI – AI Website Builder & AI Agent 0.0.8, at inc/core/snippet-store.php

1,824 lines 60.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet Store — single source of truth for ZIP AI snippet manifest I/O.
4 *
5 * All classes that read or write the snippet manifest MUST use this class.
6 * Never read/write manifest.php directly.
7 *
8 * Storage format: manifest.php with `<?php exit;` guard on line 1, JSON on line 2+.
9 * This prevents HTTP access on ALL servers (Apache, Nginx, LiteSpeed).
10 *
11 * @since 0.1.0
12 * @package zip-ai
13 */
14
15 namespace ZipAI\MCP\Classes\Core;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Persists and retrieves user-defined MCP snippets on disk.
23 *
24 * @phpstan-type ConditionRow array{type: string, operator: string, values: list<string>, connector: string, meta: array<string, mixed>}
25 */
26 class Snippet_Store {
27
28 /**
29 * PHP exit guard prepended to manifest file.
30 *
31 * @var string
32 */
33 private const GUARD = "<?php exit; // ZIP AI Snippets manifest. ?>\n";
34
35 /**
36 * Opening tag + ABSPATH guard every stored PHP snippet file begins with.
37 * `wrap_php()` writes it, `unwrap_php()` matches it byte-for-byte.
38 *
39 * @var string
40 */
41 private const PHP_WRAPPER_PREFIX = "<?php\nif ( ! defined( 'ABSPATH' ) ) { exit; }\n";
42
43 /**
44 * Allowed file types for snippets.
45 *
46 * @since 0.0.5
47 * @var list<string>
48 */
49 const ALLOWED_TYPES = array( 'php', 'js', 'css', 'html' );
50
51 /**
52 * Valid PHP execution hooks.
53 *
54 * @since 0.0.5
55 * @var list<string>
56 */
57 const VALID_PHP_HOOKS = array(
58 'plugins_loaded',
59 'init',
60 'wp_loaded',
61 'wp_enqueue_scripts',
62 'wp_head',
63 'wp_footer',
64 'template_redirect',
65 'admin_init',
66 'admin_head',
67 'admin_footer',
68 'shutdown',
69 );
70
71 /**
72 * Valid JS execution hooks.
73 *
74 * @since 0.0.5
75 * @var list<string>
76 */
77 const VALID_JS_HOOKS = array( 'wp_head', 'wp_footer', 'admin_head', 'admin_footer', 'login_head' );
78
79 /**
80 * Valid CSS execution hooks.
81 *
82 * @since 0.0.5
83 * @var list<string>
84 */
85 const VALID_CSS_HOOKS = array( 'wp_head', 'admin_head', 'login_head', 'wp_footer' );
86
87 /**
88 * Valid HTML execution hooks. HTML snippets emit verbatim markup —
89 * they're for raw `<script>` loaders (GA, GTM, FB Pixel), JSON-LD,
90 * `<noscript>` blocks, OG tags — anything that JS/CSS can't express
91 * without being wrapped in another script/style tag by the executor.
92 *
93 * @since 0.0.5
94 * @var list<string>
95 */
96 const VALID_HTML_HOOKS = array( 'wp_head', 'wp_footer', 'admin_head', 'admin_footer', 'login_head' );
97
98 /**
99 * Valid execution scopes.
100 *
101 * @since 0.0.5
102 * @var list<string>
103 */
104 const VALID_SCOPES = array( 'frontend', 'admin', 'everywhere', 'login' );
105
106 /**
107 * Valid condition types — single source consumed by REST sanitizer,
108 * agent-ability sanitizer, and the Conditions evaluator.
109 *
110 * @since 0.0.5
111 * @var list<string>
112 */
113 const VALID_CONDITION_TYPES = array(
114 'page',
115 'user',
116 'user_role',
117 'post_type',
118 'post',
119 'device',
120 'url_pattern',
121 );
122
123 /**
124 * Valid condition operators — single source.
125 *
126 * @since 0.0.5
127 * @var list<string>
128 */
129 const VALID_CONDITION_OPERATORS = array(
130 'is',
131 'is_not',
132 'in',
133 'not_in',
134 'contains',
135 'starts_with',
136 'ends_with',
137 'regex',
138 );
139
140 /**
141 * Default blocked PHP function names. Filterable via
142 * `zip_ai_snippets_blocked_functions` (additive only — defaults can never
143 * be removed).
144 *
145 * @since 0.0.5
146 * @return list<string>
147 */
148 public static function default_blocked_functions() {
149 return array(
150 // Shell execution
151 'exec',
152 'shell_exec',
153 'system',
154 'passthru',
155 'popen',
156 'proc_open',
157 'pcntl_exec',
158 // Destructive WordPress
159 'wp_delete_site',
160 'wp_uninitialize_site',
161 'drop_tables',
162 // Dynamic code execution
163 'eval',
164 'assert',
165 'create_function',
166 // Filesystem permissions
167 'chmod',
168 'chown',
169 'chgrp',
170 'symlink',
171 'link',
172 // Environment manipulation
173 'putenv',
174 'dl',
175 // PHP internals exposure
176 'phpinfo',
177 'show_source',
178 'highlight_file',
179 // Process manipulation
180 'proc_nice',
181 'proc_terminate',
182 // Low-level sockets — use wp_remote_* instead
183 'fsockopen',
184 'stream_socket_client',
185 );
186 }
187
188 /**
189 * Get effective blocked-functions list (defaults + user filter additions).
190 * Defaults are never removable.
191 *
192 * @since 0.0.5
193 * @return list<string>
194 */
195 public static function blocked_functions() {
196 $defaults = self::default_blocked_functions();
197 $custom = array_map( array( Utils::class, 'to_str' ), (array) apply_filters( 'zip_ai_snippets_blocked_functions', $defaults ) );
198 return array_values( array_unique( array_merge( $defaults, $custom ) ) );
199 }
200
201 /**
202 * Detect a PHP shell-execution backtick operator in `$code`.
203 *
204 * A naïve `preg_match('/`/', ...)` false-positives on backticks that live
205 * inside embedded HTML / JS / CSS blocks of a PHP file (JS template
206 * literals such as `` `hello ${name}` ``), which are never evaluated by
207 * PHP and pose no risk. PHP's own tokenizer is the only reliable way to
208 * distinguish the shell-exec operator from those benign occurrences.
209 *
210 * @since 0.0.5
211 * @param string $code Snippet body as authored by the user (no opening tag).
212 * @return bool True iff a real PHP shell-exec backtick token exists.
213 */
214 public static function has_php_shell_exec_backticks( $code ) {
215 if ( strpos( $code, '`' ) === false ) {
216 return false;
217 }
218
219 $prefixed = ( strncmp( ltrim( $code ), '<?', 2 ) === 0 ) ? $code : "<?php\n" . $code;
220 $tokens = @token_get_all( $prefixed ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- tokenizer may warn on malformed user PHP.
221
222 foreach ( $tokens as $token ) {
223 // token_get_all returns either [id, text, line] arrays for named
224 // tokens or raw single-character strings for punctuation. The
225 // shell-exec operator surfaces as the bare '`' character; backticks
226 // inside T_INLINE_HTML / T_CONSTANT_ENCAPSED_STRING /
227 // T_ENCAPSED_AND_WHITESPACE / comments are absorbed into the
228 // surrounding token's text and never appear at this layer.
229 if ( is_string( $token ) && '`' === $token ) {
230 return true;
231 }
232 }
233 return false;
234 }
235
236 /**
237 * Scan PHP code for blocked dangerous functions / patterns.
238 *
239 * Single source — replaces the previously-duplicated private
240 * `check_php_blocklist()` methods in `ManageCodeSnippet` and the
241 * snippet REST controller. Returns the offending function or pattern
242 * name (suitable for use in an error message) or null when clean.
243 *
244 * @since 0.0.5
245 * @param string $code Snippet body as authored by the user.
246 * @return string|null Blocked name, or null when clean.
247 */
248 public static function check_php_blocklist( $code ) {
249 foreach ( self::blocked_functions() as $func ) {
250 if ( preg_match( '/\b' . preg_quote( $func, '/' ) . '\s*\(/i', $code ) ) {
251 return $func;
252 }
253 if ( preg_match( '/[\'"]' . preg_quote( $func, '/' ) . '[\'"]/i', $code ) ) {
254 return "{$func} (as string reference)";
255 }
256 }
257
258 if ( self::has_php_shell_exec_backticks( $code ) ) {
259 return 'shell execution (backticks)';
260 }
261
262 if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/[a-z]*e[a-z]*[\'"]/i', $code ) ) {
263 return 'preg_replace with /e modifier';
264 }
265
266 return null;
267 }
268
269 /**
270 * Per-file-type valid hook list lookup.
271 *
272 * @since 0.0.5
273 * @param string $type File type (php, js, css, html).
274 * @return list<string>
275 */
276 public static function valid_hooks_for( $type ) {
277 switch ( $type ) {
278 case 'php':
279 return self::VALID_PHP_HOOKS;
280 case 'js':
281 return self::VALID_JS_HOOKS;
282 case 'css':
283 return self::VALID_CSS_HOOKS;
284 case 'html':
285 return self::VALID_HTML_HOOKS;
286 default:
287 return array();
288 }
289 }
290
291 /**
292 * Whether a hook name is safe to persist and execute.
293 *
294 * PHP snippets may run directly on third-party plugin hooks. JS/CSS keep a
295 * curated list because they are mapped onto WordPress enqueue actions.
296 *
297 * @since 0.0.5
298 * @param string $type File type.
299 * @param string $hook Hook name.
300 * @return bool
301 */
302 public static function is_valid_execution_hook( $type, $hook ) {
303 $hook = (string) $hook;
304 if ( in_array( $hook, self::valid_hooks_for( $type ), true ) ) {
305 return true;
306 }
307 if ( 'php' !== $type ) {
308 return false;
309 }
310 return self::is_safe_custom_php_hook( $hook );
311 }
312
313 /**
314 * Validate custom PHP hook names.
315 *
316 * WordPress hook names are arbitrary strings, but snippets should only
317 * persist names that are readable, bounded, and free of whitespace/control
318 * characters. This allows common third-party hooks like
319 * `woocommerce_before_cart`, `elementor/frontend/after_render`, and
320 * `acf/save_post`.
321 *
322 * @since 0.0.5
323 * @param string $hook Hook name.
324 * @return bool
325 */
326 public static function is_safe_custom_php_hook( $hook ) {
327 $hook = trim( (string) $hook );
328 if ( '' === $hook || strlen( $hook ) > 128 ) {
329 return false;
330 }
331 return (bool) preg_match( '/^[A-Za-z_][A-Za-z0-9_\\.\\/:\\-]*$/', $hook );
332 }
333
334 /**
335 * Sanitize one execution hook while preserving valid custom PHP hooks.
336 *
337 * @since 0.0.5
338 * @param string $type File type.
339 * @param mixed $hook Raw hook value.
340 * @param string $default Default hook.
341 * @return string
342 */
343 public static function sanitize_execution_hook( $type, $hook, $default ) {
344 $hook = trim( sanitize_text_field( Utils::to_str( $hook ) ) );
345 return self::is_valid_execution_hook( $type, $hook ) ? $hook : $default;
346 }
347
348 /**
349 * Sanitize execution config. Single source consumed by REST + Ability.
350 * Always returns a fully-populated array (php/js/css/html keys), with
351 * invalid input falling back to the persisted state when given, else to
352 * defaults.
353 *
354 * @since 0.0.5
355 * @param mixed $execution Raw execution config (unknowable external input).
356 * @param mixed $persisted The snippet's persisted execution config — the merge baseline for partial updates.
357 * @return array<string, array<string, mixed>>
358 */
359 public static function sanitize_execution( $execution, $persisted = null ) {
360 $defaults = self::default_execution();
361
362 // The merge baseline is the snippet's PERSISTED execution (validated
363 // per key), falling back to defaults only where nothing is persisted.
364 // Filling gaps from defaults instead of persisted state made every
365 // partial update a reset: "move the CSS to wp_head" silently rebound
366 // the PHP file's hook/priority/scope back to init/10/everywhere too.
367 $baseline = $defaults;
368 if ( is_array( $persisted ) ) {
369 foreach ( $defaults as $type => $type_defaults ) {
370 $prior = $persisted[ $type ] ?? null;
371 if ( ! is_array( $prior ) ) {
372 continue;
373 }
374 /** @var array<string, mixed> $prior */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- PHP array keys are always array-key at runtime; values stay mixed and are validated per key below.
375 $baseline[ $type ] = self::sanitize_execution_entry( $type, $prior, $type_defaults );
376 }
377 }
378
379 if ( ! is_array( $execution ) ) {
380 return $baseline;
381 }
382
383 $result = array();
384 foreach ( $baseline as $type => $type_baseline ) {
385 $input = $execution[ $type ] ?? array();
386 if ( ! is_array( $input ) ) {
387 $result[ $type ] = $type_baseline;
388 continue;
389 }
390 /** @var array<string, mixed> $input */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- same runtime guarantee as $prior above.
391 $result[ $type ] = self::sanitize_execution_entry( $type, $input, $type_baseline );
392 }
393 return $result;
394 }
395
396 /**
397 * Sanitize one per-type execution entry against a fallback entry.
398 *
399 * Absent or invalid keys fall back to the given entry (persisted state
400 * during a partial update, defaults otherwise) — never unconditionally to
401 * the global defaults.
402 *
403 * @since 0.0.5
404 * @param string $type File type (php/js/css/html).
405 * @param array<string, mixed> $input Raw per-type input.
406 * @param array<string, mixed> $fallback Fully-populated fallback entry.
407 * @return array<string, mixed>
408 */
409 private static function sanitize_execution_entry( $type, array $input, array $fallback ) {
410 $entry = array(
411 'hook' => self::sanitize_execution_hook( $type, $input['hook'] ?? $fallback['hook'], Utils::to_str( $fallback['hook'] ?? '' ) ),
412 'priority' => max( 1, min( 99, Utils::to_int( $input['priority'] ?? $fallback['priority'] ) ) ),
413 'scope' => in_array( $input['scope'] ?? '', self::VALID_SCOPES, true )
414 ? $input['scope']
415 : $fallback['scope'],
416 );
417 if ( 'php' === $type ) {
418 $entry['accepted_args'] = max( 0, min( 10, Utils::to_int( $input['accepted_args'] ?? ( $fallback['accepted_args'] ?? 1 ) ) ) );
419 }
420 return $entry;
421 }
422
423 /**
424 * Sanitize a conditions array under the one-row-per-type model.
425 *
426 * Data model:
427 * - Each row is one constraint: { type, operator, value?, values?, meta? }
428 * - At most ONE row per `type` (post, post_type, page, user, user_role,
429 * device, url_pattern). Same-type rows submitted by clients are merged.
430 * - All rows AND together at evaluation time.
431 * - Multi-value operators (`in`/`not_in`/`starts_with`/`contains`/
432 * `ends_with`/`regex`) OR across their values inside the row.
433 *
434 * Merge rules for duplicate-type rows in the input:
435 * - All positive operators (`is`/`in`) with same type → single row with
436 * operator `in` and unique merged values.
437 * - All negative operators (`is_not`/`not_in`) with same type → single
438 * row with operator `not_in` and unique merged values.
439 * - All same single-operator on string-matchers (`starts_with`/`contains`/
440 * `ends_with`/`regex`) → single row with that operator and unique
441 * merged values (evaluator ORs across values).
442 * - Mixed direction (positive + negative) or mixed string-matchers for
443 * the same type in ONE AND chain → recorded in `rejected`; there is no
444 * faithful single-row representation, and silently keeping the first
445 * row WIDENED execution (the asked-for exclusion vanished).
446 *
447 * Nothing is dropped silently: every discarded or unrepresentable row adds
448 * a human-readable reason to `rejected`. Callers MUST refuse the write
449 * when `rejected` is non-empty — persisting fewer/looser constraints than
450 * the author asked for widens where live code runs.
451 *
452 * @since 0.0.5
453 * @param mixed $conditions Raw conditions (unknowable external input).
454 * @return array{rows: list<array<string, mixed>>, rejected: list<string>}
455 */
456 public static function sanitize_conditions( $conditions ) {
457 $rejected = array();
458 if ( ! is_array( $conditions ) ) {
459 return array(
460 'rows' => array(),
461 'rejected' => $rejected,
462 );
463 }
464
465 $normalized = array();
466 foreach ( array_values( $conditions ) as $i => $cond ) {
467 if ( ! is_array( $cond ) ) {
468 $rejected[] = "row {$i}: not an object";
469 continue;
470 }
471 $type = sanitize_key( Utils::to_str( $cond['type'] ?? '' ) );
472 if ( ! in_array( $type, self::VALID_CONDITION_TYPES, true ) ) {
473 $rejected[] = "row {$i}: unknown condition type \"{$type}\" (valid: " . implode( ', ', self::VALID_CONDITION_TYPES ) . ')';
474 continue;
475 }
476
477 // An absent operator defaults to `is`; a PRESENT but unknown one is
478 // rejected. Coercing it to `is` inverted a mistyped negation
479 // ("isnot" ran the snippet exactly where it was excluded from).
480 $op_raw = $cond['operator'] ?? 'is';
481 if ( ! in_array( $op_raw, self::VALID_CONDITION_OPERATORS, true ) ) {
482 $rejected[] = "row {$i}: unknown operator \"" . sanitize_text_field( Utils::to_str( $op_raw ) ) . '" (valid: ' . implode( ', ', self::VALID_CONDITION_OPERATORS ) . ')';
483 continue;
484 }
485 $operator = strval( $op_raw );
486
487 $values = array();
488 if ( isset( $cond['values'] ) && is_array( $cond['values'] ) ) {
489 foreach ( $cond['values'] as $v ) {
490 $v = sanitize_text_field( Utils::to_str( $v ) );
491 if ( '' !== $v ) {
492 $values[] = $v;
493 }
494 }
495 }
496 if ( isset( $cond['value'] ) && '' !== $cond['value'] ) {
497 $v = sanitize_text_field( Utils::to_str( $cond['value'] ) );
498 if ( '' !== $v ) {
499 $values[] = $v;
500 }
501 }
502 if ( empty( $values ) ) {
503 $rejected[] = "row {$i}: no usable values";
504 continue;
505 }
506
507 // Login-state normalization. `logged_in` / `logged_out` are login
508 // STATES, not roles — but the model routinely emits them under
509 // `type:"user_role"` (e.g. `user_role is_not logged_in` for a
510 // "logged-out only" snippet). The role evaluator then reads that as
511 // "doesn't have a role NAMED logged_in" → true for EVERY user → the
512 // gate is a silent no-op (a logged-out-only snippet shows for
513 // logged-in users too). Route any single login-state token to the
514 // `user` type, which the evaluator resolves via is_user_logged_in(),
515 // and fold `logged_out` into `is_not logged_in` (the only value the
516 // `user` type understands). Operators in/not_in collapse to is/is_not.
517 if ( 1 === count( $values ) && in_array( $values[0], array( 'logged_in', 'logged_out' ), true ) ) {
518 $negated = in_array( $operator, array( 'is_not', 'not_in' ), true );
519 if ( 'logged_out' === $values[0] ) {
520 $negated = ! $negated; // logged_out === NOT logged_in
521 }
522 $type = 'user';
523 $values[0] = 'logged_in';
524 $operator = $negated ? 'is_not' : 'is';
525 }
526
527 // Multi-value login-state in a ROLE LIST (e.g. `user_role not_in
528 // [logged_in, editor]`). The login token is dead under the role
529 // evaluator (no role is named that), so the login intent is silently
530 // lost. Strip login-state tokens from the role list; for a NEGATIVE
531 // operator (is_not/not_in — which AND-composes) emit a separate
532 // `user` row carrying the login intent. For a POSITIVE operator the
533 // value-level OR can't be split into AND rows, so we only strip the
534 // already-dead token (preserving runtime behaviour, cleaning the shape).
535 $extra_login_row = null;
536 if ( 'user_role' === $type && count( $values ) > 1 ) {
537 $login_tokens = array_values( array_intersect( $values, array( 'logged_in', 'logged_out' ) ) );
538 if ( ! empty( $login_tokens ) ) {
539 $values = array_values( array_diff( $values, array( 'logged_in', 'logged_out' ) ) );
540 // Compose the login row only for a single, unambiguous token
541 // under a negative operator; a contradictory pair just strips.
542 if ( 1 === count( $login_tokens ) && in_array( $operator, array( 'is_not', 'not_in' ), true ) ) {
543 // Negative operator; logged_out === NOT logged_in flips it back to positive.
544 $negated = 'logged_out' !== $login_tokens[0];
545 $extra_login_row = array(
546 'type' => 'user',
547 'operator' => $negated ? 'is_not' : 'is',
548 'values' => array( 'logged_in' ),
549 );
550 }
551 // Stripping emptied the role list — promote the login row into
552 // this row, or reject the row when no unambiguous login
553 // intent is left (dropping it silently would widen execution).
554 if ( empty( $values ) ) {
555 if ( null !== $extra_login_row ) {
556 $type = 'user';
557 $operator = $extra_login_row['operator'];
558 $values = array( 'logged_in' );
559 $extra_login_row = null;
560 } else {
561 $rejected[] = "row {$i}: user_role row contains only login-state tokens with no single unambiguous intent — use type \"user\" with value \"logged_in\" instead";
562 continue;
563 }
564 }
565 }
566 }
567
568 // `meta` is reserved for future per-row flags (e.g. case_sensitive
569 // on url_pattern). Currently no flag is consumed by the evaluator,
570 // so we strip the entire meta block at sanitize time — accepting it
571 // would mislead authors into thinking it works. Re-add to the
572 // allowlist here when an evaluator codepath consumes the flag.
573 $meta = array();
574
575 $connector = isset( $cond['connector'] ) ? strtolower( Utils::to_str( $cond['connector'] ) ) : 'and';
576 if ( 'or' !== $connector ) {
577 $connector = 'and';
578 }
579
580 $normalized[] = array(
581 'type' => $type,
582 'operator' => $operator,
583 'values' => $values,
584 'connector' => $connector,
585 'meta' => $meta,
586 );
587
588 // A login row split out of a multi-value role list ANDs with the
589 // role row in the same chain (different type, so it survives merge).
590 if ( null !== $extra_login_row ) {
591 $normalized[] = array(
592 'type' => $extra_login_row['type'],
593 'operator' => $extra_login_row['operator'],
594 'values' => $extra_login_row['values'],
595 'connector' => 'and',
596 'meta' => array(),
597 );
598 }
599 }
600
601 return array(
602 'rows' => self::merge_by_type( $normalized, $rejected ),
603 'rejected' => $rejected,
604 );
605 }
606
607 /**
608 * Merge same-type rows into one per type — but PER CHAIN, not globally.
609 * Chains are separated by `connector:"or"` on a row. Cross-chain same-
610 * type rows are legitimate (e.g. `post=2051 AND role=admin` OR
611 * `post=2052 AND role=editor`), so each chain is merged independently.
612 *
613 * @param ConditionRow[] $rows Normalized rows.
614 * @param string[] $rejected Rejection reasons, appended to by reference.
615 * @return list<array<string, mixed>> Flat list preserving chain order; connector field marks
616 * chain boundaries.
617 */
618 private static function merge_by_type( array $rows, array &$rejected = array() ) {
619 // Split into chains by `or` connector.
620 $chains = array();
621 $current = array();
622 foreach ( $rows as $row ) {
623 if ( 'or' === $row['connector'] && ! empty( $current ) ) {
624 $chains[] = $current;
625 $current = array();
626 }
627 $current[] = $row;
628 }
629 if ( ! empty( $current ) ) {
630 $chains[] = $current;
631 }
632
633 $out = array();
634 foreach ( $chains as $chain_index => $chain ) {
635 $merged_chain = self::merge_chain( $chain, $rejected );
636 foreach ( $merged_chain as $i => $row ) {
637 // First row of the FIRST chain → connector "and" (implicit).
638 // First row of subsequent chains → connector "or".
639 // Subsequent rows in any chain → connector "and".
640 $row['connector'] = ( 0 === $i && $chain_index > 0 ) ? 'or' : 'and';
641 $out[] = self::finalize_row( $row );
642 }
643 }
644 return $out;
645 }
646
647 /**
648 * Merge same-type rows within a single AND-chain. All positive → `in`;
649 * all negative → `not_in`; same string-matcher → that matcher with
650 * multi-value; mixed direction → recorded in $rejected (no faithful
651 * single-row form; callers refuse the write).
652 *
653 * @param ConditionRow[] $chain One AND-chain of normalized condition rows.
654 * @param string[] $rejected Rejection reasons, appended to by reference.
655 * @return list<ConditionRow>
656 */
657 private static function merge_chain( array $chain, array &$rejected = array() ) {
658 $positive_set = array( 'is', 'in' );
659 $negative_set = array( 'is_not', 'not_in' );
660 $pattern_set = array( 'starts_with', 'contains', 'ends_with', 'regex' );
661
662 $by_type = array();
663 $order = array();
664 foreach ( $chain as $row ) {
665 if ( ! isset( $by_type[ $row['type'] ] ) ) {
666 $by_type[ $row['type'] ] = array();
667 $order[] = $row['type'];
668 }
669 $by_type[ $row['type'] ][] = $row;
670 }
671
672 $out = array();
673 foreach ( $order as $type ) {
674 $bucket = $by_type[ $type ];
675 if ( count( $bucket ) === 1 ) {
676 $out[] = $bucket[0];
677 continue;
678 }
679 $first = $bucket[0];
680 $ops = array_values(
681 array_unique(
682 array_map(
683 static function ( $r ) {
684 return $r['operator'];
685 },
686 $bucket
687 )
688 )
689 );
690 $all_positive = ! array_diff( $ops, $positive_set );
691 $all_negative = ! array_diff( $ops, $negative_set );
692 $single_pattern_op = 1 === count( $ops ) && in_array( $ops[0], $pattern_set, true );
693
694 if ( $all_positive ) {
695 $out[] = array(
696 'type' => $type,
697 'operator' => 'in',
698 'values' => self::merge_values( $bucket ),
699 'connector' => $first['connector'],
700 'meta' => $first['meta'],
701 );
702 continue;
703 }
704 if ( $all_negative ) {
705 $out[] = array(
706 'type' => $type,
707 'operator' => 'not_in',
708 'values' => self::merge_values( $bucket ),
709 'connector' => $first['connector'],
710 'meta' => $first['meta'],
711 );
712 continue;
713 }
714 if ( $single_pattern_op ) {
715 $out[] = array(
716 'type' => $type,
717 'operator' => $ops[0],
718 'values' => self::merge_values( $bucket ),
719 'connector' => $first['connector'],
720 'meta' => $first['meta'],
721 );
722 continue;
723 }
724 // Mixed direction (or mixed string-matchers) for one type in one
725 // AND chain has no faithful single-row representation. Keeping
726 // only the first row silently WIDENED execution — the exclusion
727 // the author asked for vanished. Record it; callers refuse the
728 // write when anything lands in `rejected`.
729 $rejected[] = 'conditions mix operators (' . implode( ', ', $ops ) . ") for type \"{$type}\" in one AND chain — split them into separate OR chains (connector: \"or\") or use one row per type";
730 $out[] = $first;
731 }
732 return $out;
733 }
734
735 /**
736 * Collect unique values across a bucket of same-type rows, first-seen order.
737 *
738 * @param ConditionRow[] $bucket Same-type normalized condition rows.
739 * @return list<string> Unique values.
740 */
741 private static function merge_values( array $bucket ) {
742 $seen = array();
743 $out = array();
744 foreach ( $bucket as $row ) {
745 foreach ( $row['values'] as $v ) {
746 if ( ! isset( $seen[ $v ] ) ) {
747 $seen[ $v ] = true;
748 $out[] = $v;
749 }
750 }
751 }
752 return $out;
753 }
754
755 /**
756 * Convert the internal normalized row shape into the persistence shape:
757 * single-value rows use `value`, multi-value rows use `values`. Drops
758 * empty meta. Promotes `is`→`in` / `is_not`→`not_in` when multi-value.
759 *
760 * @param array<string, mixed> $row Internal normalized condition row.
761 * @return array<string, mixed>
762 */
763 private static function finalize_row( array $row ) {
764 $values = isset( $row['values'] ) && is_array( $row['values'] ) ? $row['values'] : array();
765 $op = $row['operator'] ?? 'is';
766
767 $persisted = array(
768 'type' => $row['type'],
769 'operator' => $op,
770 );
771
772 if ( count( $values ) > 1 ) {
773 // Multi-value: promote single-value operators to their list form.
774 if ( 'is' === $op ) {
775 $persisted['operator'] = 'in';
776 } elseif ( 'is_not' === $op ) {
777 $persisted['operator'] = 'not_in';
778 }
779 $persisted['values'] = array_values( $values );
780 } else {
781 // Single-value: demote list operators back to their scalar form so
782 // the persisted shape is canonical. Without this, `describe_row`
783 // renders `post in [2051]` instead of `post=2051` after a merge
784 // collapses to one value.
785 if ( 'in' === $op ) {
786 $persisted['operator'] = 'is';
787 } elseif ( 'not_in' === $op ) {
788 $persisted['operator'] = 'is_not';
789 }
790 $persisted['value'] = Utils::to_str( $values[0] ?? '' );
791 }
792
793 // Connector is significant: "or" marks the start of a new OR chain.
794 // "and" is the default; we still persist it explicitly so JSON shape
795 // is uniform across rows (avoid silent client-side drift).
796 $connector = isset( $row['connector'] ) ? strtolower( Utils::to_str( $row['connector'] ) ) : 'and';
797 $persisted['connector'] = ( 'or' === $connector ) ? 'or' : 'and';
798
799 if ( ! empty( $row['meta'] ) && is_array( $row['meta'] ) ) {
800 $persisted['meta'] = $row['meta'];
801 }
802 return $persisted;
803 }
804
805 /**
806 * Human-readable sentence describing when a snippet will run.
807 *
808 * Output examples:
809 * "Runs everywhere"
810 * "Runs when post_type=product"
811 * "Runs when post in [2800,2801] AND post_type=product"
812 *
813 * Under the one-row-per-type model the description is always a flat AND
814 * of row strings — no parenthesization, no group rendering.
815 *
816 * @since 0.0.5
817 * @param mixed $conditions Persisted condition rows (unknowable external input).
818 * @return string
819 */
820 public static function describe_conditions( $conditions ) {
821 if ( empty( $conditions ) || ! is_array( $conditions ) ) {
822 return 'Runs everywhere';
823 }
824
825 // Split into chains: each chain is rows joined by AND, chains OR together.
826 $chains = array();
827 $current = array();
828 foreach ( $conditions as $row ) {
829 if ( ! is_array( $row ) ) {
830 continue;
831 }
832 if ( 'or' === ( $row['connector'] ?? 'and' ) && ! empty( $current ) ) {
833 $chains[] = $current;
834 $current = array();
835 }
836 $current[] = $row;
837 }
838 if ( ! empty( $current ) ) {
839 $chains[] = $current;
840 }
841 if ( empty( $chains ) ) {
842 return 'Runs everywhere';
843 }
844
845 $rendered = array();
846 foreach ( $chains as $chain ) {
847 $row_strings = array();
848 foreach ( $chain as $row ) {
849 $row_strings[] = self::describe_row( $row );
850 }
851 $expr = implode( ' AND ', $row_strings );
852 // Parenthesize multi-row chains when there is more than one chain
853 // so AND/OR precedence reads correctly.
854 if ( count( $row_strings ) > 1 && count( $chains ) > 1 ) {
855 $expr = '(' . $expr . ')';
856 }
857 $rendered[] = $expr;
858 }
859 return 'Runs when ' . implode( ' OR ', $rendered );
860 }
861
862 /**
863 * Render a single condition row as `type[op]value` short form. Operator
864 * is omitted for the common `is` case to keep the sentence readable.
865 *
866 * @since 0.0.5
867 * @param array<array-key, mixed> $row Single condition row.
868 * @return string
869 */
870 private static function describe_row( $row ) {
871 $type = Utils::to_str( $row['type'] ?? '?' );
872 $op = Utils::to_str( $row['operator'] ?? 'is' );
873 if ( isset( $row['values'] ) && is_array( $row['values'] ) && ! empty( $row['values'] ) ) {
874 $val = implode(
875 ',',
876 array_map(
877 static function ( $v ) {
878 return Utils::to_str( $v );
879 },
880 $row['values']
881 )
882 );
883 } else {
884 $val = Utils::to_str( $row['value'] ?? '' );
885 }
886
887 switch ( $op ) {
888 case 'is':
889 return $type . '=' . $val;
890 case 'is_not':
891 return $type . '!=' . $val;
892 case 'in':
893 return $type . ' in [' . $val . ']';
894 case 'not_in':
895 return $type . ' not in [' . $val . ']';
896 case 'contains':
897 return $type . ' contains [' . $val . ']';
898 case 'starts_with':
899 return $type . ' starts_with [' . $val . ']';
900 case 'ends_with':
901 return $type . ' ends_with [' . $val . ']';
902 case 'regex':
903 return $type . ' matches [' . $val . ']';
904 default:
905 return $type . ' ' . $op . ' ' . $val;
906 }
907 }
908
909 /**
910 * Return all valid snippet slugs from the manifest. Used to enrich
911 * "not found" errors so the agent can self-correct on a typo or stale
912 * slug reference.
913 *
914 * @since 0.0.5
915 * @return list<string>
916 */
917 public static function known_slugs() {
918 $manifest = self::load();
919 return array_values(
920 array_filter(
921 array_keys( $manifest ),
922 static function ( $k ) {
923 return '_version' !== $k; }
924 )
925 );
926 }
927
928 /**
929 * Format a "not found" diagnostic with up to N known slugs for the agent
930 * to fuzzy-match against.
931 *
932 * @since 0.0.5
933 * @param string $requested Slug the caller asked for.
934 * @return string
935 */
936 public static function not_found_message( $requested ) {
937 $known = self::known_slugs();
938 if ( empty( $known ) ) {
939 return sprintf( 'Snippet "%s" not found. No snippets exist yet.', (string) $requested );
940 }
941 $preview = array_slice( $known, 0, 20 );
942 $suffix = count( $known ) > 20 ? sprintf( ' (and %d more)', count( $known ) - 20 ) : '';
943 return sprintf(
944 'Snippet "%s" not found. Known slugs: %s%s. Call action=list to inspect all snippets.',
945 (string) $requested,
946 implode( ', ', $preview ),
947 $suffix
948 );
949 }
950
951 /**
952 * Default execution settings per file type.
953 *
954 * @since 0.0.5
955 * @return array{php: array{hook: string, priority: int, scope: string, accepted_args: int}, js: array{hook: string, priority: int, scope: string}, css: array{hook: string, priority: int, scope: string}, html: array{hook: string, priority: int, scope: string}}
956 */
957 public static function default_execution() {
958 return array(
959 'php' => array(
960 'hook' => 'init',
961 'priority' => 10,
962 'scope' => 'everywhere',
963 'accepted_args' => 1,
964 ),
965 'js' => array(
966 'hook' => 'wp_footer',
967 'priority' => 10,
968 'scope' => 'frontend',
969 ),
970 'css' => array(
971 'hook' => 'wp_head',
972 'priority' => 10,
973 'scope' => 'frontend',
974 ),
975 'html' => array(
976 'hook' => 'wp_head',
977 'priority' => 10,
978 'scope' => 'frontend',
979 ),
980 );
981 }
982
983 /**
984 * Normalize a snippet's metadata by filling missing fields with defaults.
985 *
986 * Ensures backward compatibility — old manifests without execution/conditions
987 * fields get sensible defaults so downstream code never needs null-checks.
988 *
989 * @since 0.0.5
990 * @param array<string, mixed> $meta Raw snippet metadata from manifest.
991 * @return array<string, mixed> Normalized metadata with all fields present.
992 */
993 public static function normalize_snippet( $meta ) {
994 $defaults = array(
995 'title' => '',
996 'description' => '',
997 'files' => array(),
998 'file_hashes' => array(),
999 'enabled' => false,
1000 'auto_disabled' => false,
1001 'disabled_reason' => null,
1002 'disabled_at' => null,
1003 'execution' => self::default_execution(),
1004 'conditions' => array(),
1005 'sandbox' => false,
1006 'sandbox_user' => null,
1007 'created' => null,
1008 'created_by' => null,
1009 'updated' => null,
1010 'updated_by' => null,
1011 'updated_by_type' => null, // user|agent|system — author type of last edit
1012 'current_version_id' => null, // pointer into .versions/index.json
1013 'versions_count' => 0, // denormalized for list-page badge
1014 'last_version_at' => null, // ISO 8601 timestamp of latest version
1015 );
1016
1017 $merged = array_merge( $defaults, $meta );
1018
1019 // Ensure execution has entries for all file types with defaults.
1020 $default_exec = self::default_execution();
1021 $execution = is_array( $merged['execution'] ) ? $merged['execution'] : array();
1022 foreach ( $default_exec as $type => $type_defaults ) {
1023 $entry = $execution[ $type ] ?? null;
1024 $execution[ $type ] = is_array( $entry ) ? array_merge( $type_defaults, $entry ) : $type_defaults;
1025 }
1026
1027 // Validate execution hooks and scopes.
1028 foreach ( array_keys( $default_exec ) as $type ) {
1029 $entry = is_array( $execution[ $type ] ) ? $execution[ $type ] : array();
1030 $entry['hook'] = self::sanitize_execution_hook(
1031 $type,
1032 $entry['hook'] ?? '',
1033 $default_exec[ $type ]['hook']
1034 );
1035 if ( ! in_array( $entry['scope'] ?? '', self::VALID_SCOPES, true ) ) {
1036 $entry['scope'] = $default_exec[ $type ]['scope'];
1037 }
1038 $entry['priority'] = max( 1, min( 99, Utils::to_int( $entry['priority'] ?? 0 ) ) );
1039 if ( 'php' === $type ) {
1040 $entry['accepted_args'] = max( 0, min( 10, Utils::to_int( $entry['accepted_args'] ?? 1 ) ) );
1041 }
1042 $execution[ $type ] = $entry;
1043 }
1044 $merged['execution'] = $execution;
1045
1046 // Ensure conditions is an array.
1047 if ( ! is_array( $merged['conditions'] ) ) {
1048 $merged['conditions'] = array();
1049 }
1050
1051 return $merged;
1052 }
1053
1054 /**
1055 * Get the base snippets directory.
1056 *
1057 * Filterable via 'zip_ai_snippets_base_dir'. Always validated to be under WP_CONTENT_DIR.
1058 *
1059 * @since 0.1.0
1060 * @return string Absolute directory path.
1061 */
1062 public static function base_dir() {
1063 $dir = apply_filters( 'zip_ai_snippets_base_dir', WP_CONTENT_DIR . '/zip-ai-snippets' );
1064 $dir = is_string( $dir ) ? $dir : WP_CONTENT_DIR . '/zip-ai-snippets';
1065
1066 $real = realpath( $dir );
1067 $real = $real ? $real : $dir;
1068 $base = realpath( WP_CONTENT_DIR );
1069 $base = $base ? $base : WP_CONTENT_DIR;
1070 if ( strpos( $real, $base ) !== 0 ) {
1071 return WP_CONTENT_DIR . '/zip-ai-snippets';
1072 }
1073
1074 return $dir;
1075 }
1076
1077 /**
1078 * Capability required to put PHP on disk or turn it on.
1079 *
1080 * Snippet bodies are executable PHP, so authoring them is the plugin/theme
1081 * file editor's boundary, not `manage_options`. WP maps `edit_plugins`
1082 * through `DISALLOW_FILE_EDIT`, `wp_is_file_mod_allowed()` and — on
1083 * multisite — the super-admin check, so this one cap covers all three.
1084 * Filterable so a site that manages snippets with a custom role can swap it
1085 * out.
1086 *
1087 * Applies to: create, update, import, restore-version, enable, sandbox, and
1088 * turning safe mode OFF.
1089 *
1090 * @since 0.0.5
1091 * @return string
1092 */
1093 public static function manage_capability() {
1094 $cap = apply_filters( 'zip_ai_snippets_capability', 'edit_plugins' );
1095 return is_string( $cap ) && '' !== $cap ? $cap : 'edit_plugins';
1096 }
1097
1098 /**
1099 * Capability required to inspect snippets or switch them OFF.
1100 *
1101 * Deliberately lower than `manage_capability()`. The executor has no cap
1102 * check — an already-enabled snippet keeps running on every request — so
1103 * gating the read and disable paths behind the file-editor cap would leave a
1104 * `DISALLOW_FILE_EDIT` host (WP Engine, Kinsta, Flywheel, Pantheon) or a
1105 * multisite sub-site admin with live snippets and no supported way to see or
1106 * stop them. Inspecting and disabling is the safety valve; authoring is the
1107 * privilege.
1108 *
1109 * Applies to: list, read, export, version list/get/diff, delete, disable,
1110 * and turning safe mode ON.
1111 *
1112 * @since 0.0.5
1113 * @return string
1114 */
1115 public static function read_capability() {
1116 $cap = apply_filters( 'zip_ai_snippets_read_capability', 'manage_options' );
1117 return is_string( $cap ) && '' !== $cap ? $cap : 'manage_options';
1118 }
1119
1120 /**
1121 * Whether the current user may author or enable snippets.
1122 *
1123 * @since 0.0.5
1124 * @return bool
1125 */
1126 public static function current_user_can_manage() {
1127 return current_user_can( self::manage_capability() );
1128 }
1129
1130 /**
1131 * Whether the current user may inspect or disable snippets.
1132 *
1133 * @since 0.0.5
1134 * @return bool
1135 */
1136 public static function current_user_can_read() {
1137 return current_user_can( self::read_capability() );
1138 }
1139
1140 /**
1141 * Get the manifest file path.
1142 *
1143 * @since 0.1.0
1144 * @return string
1145 */
1146 public static function manifest_path() {
1147 return self::base_dir() . '/manifest.php';
1148 }
1149
1150 /**
1151 * Load the manifest as an array.
1152 *
1153 * @since 0.1.0
1154 * @return array<string, mixed> Manifest data, or empty array if missing/corrupt.
1155 */
1156 public static function load() {
1157 // Read paths degrade to an empty list on a damaged manifest so the
1158 // site keeps rendering; WRITE paths must not — with_lock() refuses to
1159 // write over a file that exists but does not parse (see
1160 // manifest_is_unreadable()), otherwise the next save would persist a
1161 // near-empty registry and deregister every other snippet.
1162 $path = self::manifest_path();
1163 if ( ! file_exists( $path ) ) {
1164 return array();
1165 }
1166
1167 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1168 if ( false === $raw ) {
1169 return array();
1170 }
1171
1172 $newline = strpos( $raw, "\n" );
1173 if ( false === $newline ) {
1174 return array();
1175 }
1176
1177 $json = substr( $raw, $newline + 1 );
1178 $data = json_decode( $json, true );
1179
1180 if ( ! is_array( $data ) ) {
1181 return array();
1182 }
1183
1184 $manifest = array();
1185 foreach ( $data as $key => $value ) {
1186 $manifest[ (string) $key ] = $value;
1187 }
1188 return $manifest;
1189 }
1190
1191 /**
1192 * Whether the manifest file EXISTS but cannot be parsed back into a
1193 * registry (unreadable, truncated write, manual damage).
1194 *
1195 * Distinguishes "no manifest yet" (a legitimate empty registry — returns
1196 * false) from "there was a registry and it is damaged" (returns true).
1197 * Write paths must check this and refuse: rebuilding over a damaged file
1198 * silently deregisters every snippet the damaged file still names.
1199 *
1200 * @since 0.1.0
1201 * @return bool
1202 */
1203 public static function manifest_is_unreadable() {
1204 $path = self::manifest_path();
1205 if ( ! file_exists( $path ) ) {
1206 return false;
1207 }
1208
1209 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1210 if ( false === $raw ) {
1211 return true;
1212 }
1213
1214 $newline = strpos( $raw, "\n" );
1215 $json = false === $newline ? '' : substr( $raw, $newline + 1 );
1216
1217 return ! is_array( json_decode( $json, true ) );
1218 }
1219
1220 /**
1221 * Resolve the initialized WP_Filesystem instance — the plugin's blessed FS
1222 * layer (same pattern as media-service / the plugin installers). Returns
1223 * null on hosts where it can't initialize without stored credentials
1224 * (FTP/SSH mode) so callers degrade rather than fatal.
1225 *
1226 * @return \WP_Filesystem_Base|null
1227 */
1228 private static function fs() {
1229 global $wp_filesystem;
1230 if ( ! $wp_filesystem ) {
1231 require_once ABSPATH . 'wp-admin/includes/file.php';
1232 WP_Filesystem();
1233 }
1234 return $wp_filesystem instanceof \WP_Filesystem_Base ? $wp_filesystem : null;
1235 }
1236
1237 /**
1238 * Write a file through WP_Filesystem.
1239 *
1240 * @param string $path Absolute file path.
1241 * @param string $content File contents.
1242 * @return bool True on success; false if WP_Filesystem is unavailable or the write failed.
1243 */
1244 public static function put_file( $path, $content ) {
1245 $fs = self::fs();
1246 return $fs && $fs->put_contents( $path, $content, FS_CHMOD_FILE );
1247 }
1248
1249 /**
1250 * Wrap an author's PHP body in the stored file format: one opening tag, an
1251 * ABSPATH guard, and a title comment.
1252 *
1253 * SECURITY: the executor `include`s these files, so a direct HTTP hit on
1254 * the file would run the body too. The guard makes a directly-fetched
1255 * snippet exit instead — the last line of defense behind the randomized
1256 * directory name and the per-dir deny files, and the only one that holds on
1257 * a server where `.htaccess`/`web.config` are both inert.
1258 *
1259 * Idempotent: the input is unwrapped first, so re-saving an already-stored
1260 * body never stacks guards.
1261 *
1262 * The header line is always written (empty title included) so `unwrap_php()`
1263 * can strip exactly one line without guessing.
1264 *
1265 * @since 0.0.5
1266 * @param string $code Author's PHP body (with or without an opening tag).
1267 * @param string $title Snippet title for the header comment.
1268 * @return string Contents to write to disk.
1269 */
1270 public static function wrap_php( $code, $title = '' ) {
1271 $body = self::strip_open_tag( rtrim( self::unwrap_php( $code ), "\n" ) );
1272 $header = rtrim( '// Snippet: ' . self::comment_safe_title( $title ) );
1273 return self::PHP_WRAPPER_PREFIX . $header . "\n" . $body . "\n";
1274 }
1275
1276 /**
1277 * Drop an author-supplied opening tag so the wrapper cannot nest a second
1278 * one — `<?php` inside an already-open PHP file is a parse error, and the
1279 * body's own gates never see it because they only ever inspect the body.
1280 *
1281 * `<?=` becomes `echo ` so the shorthand keeps its output semantics instead
1282 * of leaving a bare expression. Anything else that merely looks like a tag
1283 * (`<?xml`, `<?foo`) is left alone — `prepare_php_file()` refuses to write
1284 * an unparseable result rather than guess at it.
1285 *
1286 * @since 0.0.5
1287 * @param string $body Author's PHP body.
1288 * @return string Body with no leading opening tag.
1289 */
1290 private static function strip_open_tag( $body ) {
1291 if ( 1 === preg_match( '/\A<\?=[ \t]*/', $body ) ) {
1292 return (string) preg_replace( '/\A<\?=[ \t]*/', 'echo ', $body, 1 );
1293 }
1294 return (string) preg_replace( '/\A<\?(?:php)?(?:[ \t]+|\R|\z)/i', '', $body, 1 );
1295 }
1296
1297 /**
1298 * Wrap a PHP body for storage and refuse to hand back anything the parser
1299 * rejects.
1300 *
1301 * SECURITY / correctness belt: every write gate (`check_php_blocklist()`,
1302 * `Snippet_Lint::check()`, `detect_syntax_error()`) inspects the BODY, and
1303 * `hash_file()` runs after the write, so a wrapper bug that produced an
1304 * unparseable file would pass all of them, read back clean through
1305 * `unwrap_php()`, and then be swallowed at runtime by the executor's
1306 * `catch ( \Throwable )` — a snippet that silently never runs. Checking the
1307 * bytes we are about to write is the only place that catches it.
1308 *
1309 * @since 0.0.5
1310 * @param string $code Author's PHP body.
1311 * @param string $title Snippet title for the header comment.
1312 * @return string|\WP_Error Contents to write, or an error if they would not parse.
1313 */
1314 public static function prepare_php_file( $code, $title = '' ) {
1315 $stored = self::wrap_php( $code, $title );
1316 $error = Snippet_Lint::detect_syntax_error( $stored );
1317
1318 if ( null !== $error ) {
1319 return new \WP_Error(
1320 'wrapped_syntax_error',
1321 sprintf(
1322 /* translators: %s: parser error message. */
1323 __( 'Refused to store an unparseable snippet file: %s', 'zip-ai' ),
1324 $error
1325 )
1326 );
1327 }
1328
1329 return $stored;
1330 }
1331
1332 /**
1333 * Flatten a title so it cannot escape the `// Snippet: …` header line.
1334 *
1335 * SECURITY: the header is interpolated into the stored PHP file, and the
1336 * import path takes its title straight from the payload — a title carrying
1337 * a newline would put the remainder on its own line as executable code that
1338 * `validate_code()` (blocklist, lint, syntax check) never inspected, since
1339 * those run on the body only. `?>` would likewise end the PHP block early.
1340 * Sanitising here covers all four writers rather than each call site.
1341 *
1342 * @since 0.0.5
1343 * @param string $title Raw title, possibly attacker-supplied.
1344 * @return string Single-line, comment-safe title.
1345 */
1346 private static function comment_safe_title( $title ) {
1347 return trim( str_replace( array( "\0", "\r", "\n", '?>' ), ' ', (string) $title ) );
1348 }
1349
1350 /**
1351 * Strip the stored prefix (opening tag, ABSPATH guard, title comment) back
1352 * to the author's body. Used on every read that lints, diffs, exports or
1353 * shows code — the wrapper is storage plumbing, not the user's snippet.
1354 *
1355 * Content that does not begin with the exact wrapper prefix is returned
1356 * untouched: an author body that happens to open with `// Snippet: …` keeps
1357 * that line, and a pre-wrapper file is never partially eaten.
1358 *
1359 * @since 0.0.5
1360 * @param string $content Stored file contents.
1361 * @return string Author's body.
1362 */
1363 public static function unwrap_php( $content ) {
1364 $content = (string) $content;
1365
1366 // Structural match on the prefix, line-ending agnostic: a file edited
1367 // over SFTP can come back CRLF, and a byte compare would then treat our
1368 // own wrapper as author content.
1369 if ( 1 !== preg_match( '/\A<\?php\Rif \( ! defined\( \'ABSPATH\' \) \) \{ exit; \}\R/', $content, $matches ) ) {
1370 return $content;
1371 }
1372
1373 $body = substr( $content, strlen( $matches[0] ) );
1374
1375 // Our header is the line immediately after the guard, when present.
1376 return (string) preg_replace( '#\A// Snippet:.*\R#', '', $body, 1 );
1377 }
1378
1379 /**
1380 * Recursively delete a directory through WP_Filesystem.
1381 *
1382 * @param string $dir Absolute directory path.
1383 * @return bool True on success; false if WP_Filesystem is unavailable or the delete failed.
1384 */
1385 public static function delete_dir( $dir ) {
1386 $fs = self::fs();
1387 return $fs && $fs->delete( $dir, true, 'd' );
1388 }
1389
1390 /**
1391 * Save manifest data to disk.
1392 *
1393 * @since 0.1.0
1394 * @param array<string, mixed> $data Manifest data.
1395 * @return bool True on success.
1396 */
1397 public static function save( array $data ) {
1398 $content = self::GUARD . wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
1399
1400 return self::put_file( self::manifest_path(), $content );
1401 }
1402
1403 /**
1404 * Run a callback with an exclusive lock held across the manifest's full
1405 * read-modify-write window. Solves the lost-update race where parallel
1406 * REST requests (bulk delete, concurrent MCP tool calls) each `load()`
1407 * the same baseline, each `unset()` their slug, each `save()` — second
1408 * writer wins, the first deletion silently comes back, the UI surfaces
1409 * the resurrected entry as a "leftover".
1410 *
1411 * Usage:
1412 * Snippet_Store::with_lock( function ( &$manifest ) use ( $slug ) {
1413 * unset( $manifest[ $slug ] );
1414 * } );
1415 *
1416 * Best-effort: when `flock()` is unavailable on the host (some hosted
1417 * filesystems don't support advisory locking), falls back to a plain
1418 * load/save sequence so callers don't break — same behavior as before
1419 * this helper existed.
1420 *
1421 * @param callable $callback Receives the manifest array by reference and mutates it in place.
1422 * @return bool True on save success.
1423 */
1424 public static function with_lock( callable $callback ) {
1425 $path = self::manifest_path();
1426
1427 if ( ! file_exists( $path ) ) {
1428 // First-write case — there is nothing to read-lock. Hand the
1429 // callback an empty array, then save() (which already takes
1430 // LOCK_EX for the write).
1431 $manifest = array();
1432 $callback( $manifest );
1433 return self::save( $manifest );
1434 }
1435
1436 // phpcs:ignore WordPress.WP.AlternativeFunctions
1437 $fp = @fopen( $path, 'r+' );
1438 if ( false === $fp ) {
1439 // Cannot open for read+write — fall back to a plain load/save so
1440 // callers degrade rather than fail. The corruption guard still
1441 // applies: never rebuild a registry over a file we could not read.
1442 if ( self::manifest_is_unreadable() ) {
1443 return false;
1444 }
1445 $manifest = self::load();
1446 $callback( $manifest );
1447 return self::save( $manifest );
1448 }
1449
1450 $locked = @flock( $fp, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_flock, WordPress.PHP.NoSilencedErrors.Discouraged -- advisory locking for the atomic manifest read-modify-write; WP_Filesystem has no equivalent.
1451 try {
1452 // phpcs:ignore WordPress.WP.AlternativeFunctions
1453 $raw = stream_get_contents( $fp );
1454 $newline = is_string( $raw ) ? strpos( $raw, "\n" ) : false;
1455 $json = false === $newline ? '' : substr( $raw, $newline + 1 );
1456 $data = json_decode( $json, true );
1457
1458 // The file EXISTS but does not parse (truncated write, manual
1459 // damage). Proceeding with an empty array would let the next
1460 // save() persist a one-entry registry and silently deregister
1461 // every other snippet — refuse the write instead; the registry
1462 // needs to be repaired or the file removed deliberately.
1463 if ( ! is_array( $data ) ) {
1464 return false;
1465 }
1466 $manifest = $data;
1467
1468 $callback( $manifest );
1469
1470 $content = self::GUARD . wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
1471
1472 rewind( $fp );
1473 ftruncate( $fp, 0 ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_ftruncate -- atomic manifest rewrite under flock; WP_Filesystem has no equivalent.
1474 $written = fwrite( $fp, $content ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite -- atomic manifest rewrite under flock; WP_Filesystem has no equivalent.
1475 fflush( $fp );
1476
1477 return false !== $written;
1478 } finally {
1479 if ( $locked ) {
1480 @flock( $fp, LOCK_UN ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_flock, WordPress.PHP.NoSilencedErrors.Discouraged -- releases the manifest advisory lock; WP_Filesystem has no equivalent.
1481 }
1482 fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1483 }
1484 }
1485
1486 /**
1487 * Validate and sanitize a snippet slug.
1488 *
1489 * Prevents path traversal, reserved names, and special characters.
1490 *
1491 * @since 0.1.0
1492 * @param string $slug Raw slug input.
1493 * @return string|false Sanitized slug, or false if invalid.
1494 */
1495 public static function validate_slug( $slug ) {
1496 $slug = sanitize_file_name( $slug );
1497
1498 if ( empty( $slug ) || '_version' === $slug || '.' === $slug || '..' === $slug ) {
1499 return false;
1500 }
1501
1502 if ( preg_match( '/[\/\\\\]|\.\./', $slug ) ) {
1503 return false;
1504 }
1505
1506 $base = realpath( self::base_dir() );
1507 $base = $base ? $base : self::base_dir();
1508 $target = $base . '/' . $slug;
1509 if ( strpos( $target, $base . '/' ) !== 0 ) {
1510 return false;
1511 }
1512
1513 return $slug;
1514 }
1515
1516 /**
1517 * Check if a file path is safely within the snippets base directory.
1518 *
1519 * @since 0.1.0
1520 * @param string $filepath Absolute file path to check.
1521 * @return bool
1522 */
1523 public static function is_safe_path( $filepath ) {
1524 $real_base = realpath( self::base_dir() );
1525 if ( ! $real_base ) {
1526 return false;
1527 }
1528
1529 // For new files that don't exist yet, check the parent directory.
1530 $check = file_exists( $filepath ) ? $filepath : dirname( $filepath );
1531 $real = realpath( $check );
1532 if ( ! $real ) {
1533 return false;
1534 }
1535
1536 return strpos( $real, $real_base . DIRECTORY_SEPARATOR ) === 0
1537 || $real === $real_base;
1538 }
1539
1540 /**
1541 * Per-process cache of slug → on-disk dir-name lookups. Avoids reloading
1542 * the manifest for every snippet_dir/snippet_file call within a request.
1543 *
1544 * @var array<string,string>
1545 */
1546 private static $dir_cache = array();
1547
1548 /**
1549 * Mint a randomized on-disk directory name for a new snippet.
1550 *
1551 * Format: `{slug}-{8 hex chars}`. Pure-random suffix means an attacker
1552 * can't guess the path from the title alone — defense against direct-URL
1553 * fetching on web servers where `.htaccess` is inert (Nginx, Caddy, IIS).
1554 * The human-readable slug is preserved as a prefix for filesystem
1555 * inspection.
1556 *
1557 * @since 0.0.5
1558 * @param string $slug Validated slug.
1559 * @return string Storage dir basename.
1560 */
1561 public static function mint_storage_dir( $slug ) {
1562 $suffix = function_exists( 'wp_generate_password' )
1563 ? strtolower( wp_generate_password( 8, false, false ) )
1564 : bin2hex( random_bytes( 4 ) );
1565 // Strip any non [a-z0-9] just in case (wp_generate_password without
1566 // special chars already gives alphanumeric — belt-and-braces).
1567 $suffix = preg_replace( '/[^a-z0-9]/', '', $suffix ) ?? '';
1568 if ( strlen( $suffix ) < 8 ) {
1569 $suffix = str_pad( $suffix, 8, '0' );
1570 }
1571 return $slug . '-' . substr( $suffix, 0, 8 );
1572 }
1573
1574 /**
1575 * Resolve a slug to its on-disk directory basename via the manifest.
1576 * New snippets carry an explicit `dir` field (randomized prefix +
1577 * suffix). Legacy snippets without `dir` fall back to the slug, so
1578 * existing folders keep working without migration.
1579 *
1580 * @since 0.0.5
1581 * @param string $slug Validated snippet slug.
1582 * @return string
1583 */
1584 public static function resolve_dir_name( $slug ) {
1585 if ( isset( self::$dir_cache[ $slug ] ) ) {
1586 return self::$dir_cache[ $slug ];
1587 }
1588 $manifest = self::load();
1589 $entry = $manifest[ $slug ] ?? null;
1590 $dir = is_array( $entry ) && isset( $entry['dir'] ) && is_string( $entry['dir'] ) && '' !== $entry['dir']
1591 ? $entry['dir']
1592 : $slug;
1593 self::$dir_cache[ $slug ] = $dir;
1594 return $dir;
1595 }
1596
1597 /**
1598 * Reset the dir cache. Call after a write that may have changed the
1599 * `dir` field — primarily create / restore-from-version paths.
1600 *
1601 * @since 0.0.5
1602 * @return void
1603 */
1604 public static function clear_dir_cache() {
1605 self::$dir_cache = array();
1606 }
1607
1608 /**
1609 * Stash a slug → dir-name binding into the cache. Used by the create
1610 * flow to expose a freshly-minted random dir to `snippet_dir()` before
1611 * the manifest is persisted (otherwise the fallback would point at
1612 * `base_dir()/{slug}` instead of `base_dir()/{slug}-{hex}`).
1613 *
1614 * @since 0.0.5
1615 * @param string $slug Validated snippet slug.
1616 * @param string $dir On-disk directory basename.
1617 * @return void
1618 */
1619 public static function set_dir_cache( $slug, $dir ) {
1620 self::$dir_cache[ $slug ] = $dir;
1621 }
1622
1623 /**
1624 * Get the full path to a snippet's directory.
1625 *
1626 * @since 0.1.0
1627 * @param string $slug Validated snippet slug.
1628 * @return string
1629 */
1630 public static function snippet_dir( $slug ) {
1631 return self::base_dir() . '/' . self::resolve_dir_name( $slug );
1632 }
1633
1634 /**
1635 * Get the full path to a snippet file.
1636 *
1637 * @since 0.1.0
1638 * @param string $slug Validated snippet slug.
1639 * @param string $type File type (php, js, css).
1640 * @return string
1641 */
1642 public static function snippet_file( $slug, $type ) {
1643 return self::snippet_dir( $slug ) . '/snippet.' . $type;
1644 }
1645
1646 /**
1647 * Apache/LiteSpeed `.htaccess` body that denies all direct HTTP access
1648 * to the snippets dir tree. Idempotent — safe to write at any time.
1649 *
1650 * @since 0.0.5
1651 * @return string
1652 */
1653 public static function htaccess_content() {
1654 return "# ZIP AI snippets — block all direct HTTP access.\n"
1655 . "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
1656 . "<IfModule !mod_authz_core.c>\n Order Deny,Allow\n Deny from all\n</IfModule>\n";
1657 }
1658
1659 /**
1660 * Recommended Nginx location block for hosts where `.htaccess` is inert.
1661 * Written as a documentation file inside the base dir so admins can
1662 * paste the snippet into their server config.
1663 *
1664 * @since 0.0.5
1665 * @return string
1666 */
1667 public static function nginx_config_content() {
1668 $rel = '/wp-content/' . basename( self::base_dir() ) . '/';
1669 return "# ZIP AI snippets — paste this into your Nginx server block.\n"
1670 . "# Blocks direct HTTP access to snippet source on hosts where\n"
1671 . "# .htaccess is inert. The plugin's PHP executor still includes\n"
1672 . "# these files server-side; this only blocks the public URL path.\n"
1673 . "\n"
1674 . "location ~* ^{$rel} {\n"
1675 . " deny all;\n"
1676 . " return 403;\n"
1677 . "}\n";
1678 }
1679
1680 /**
1681 * IIS `web.config` body that denies direct HTTP access. `.htaccess` is
1682 * inert on IIS, so the same deny has to be expressed in its own dialect.
1683 *
1684 * @since 0.0.5
1685 * @return string
1686 */
1687 public static function web_config_content() {
1688 // Request Filtering, not URL Authorization: `<security><authorization>`
1689 // needs the URL Authorization role feature installed, and IIS answers
1690 // 500.19 for the whole directory without it — a deny that comes from a
1691 // config error, and evaporates the moment someone fixes the 500.
1692 // `requestFiltering` ships enabled by default and is delegated to
1693 // web.config, so denying the extension is both valid and durable.
1694 return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
1695 . "<!-- ZIP AI snippets — block direct HTTP access to snippet source on IIS. -->\n"
1696 . "<configuration>\n"
1697 . " <system.webServer>\n"
1698 . " <security>\n"
1699 . " <requestFiltering>\n"
1700 . " <fileExtensions>\n"
1701 . " <add fileExtension=\".php\" allowed=\"false\" />\n"
1702 . " <add fileExtension=\".js\" allowed=\"false\" />\n"
1703 . " <add fileExtension=\".css\" allowed=\"false\" />\n"
1704 . " <add fileExtension=\".html\" allowed=\"false\" />\n"
1705 . " </fileExtensions>\n"
1706 . " </requestFiltering>\n"
1707 . " </security>\n"
1708 . " </system.webServer>\n"
1709 . "</configuration>\n";
1710 }
1711
1712 /**
1713 * Idempotent .htaccess + web.config + index.php protection at a directory
1714 * path. Used for both the base dir and each per-snippet subdir.
1715 *
1716 * @since 0.0.5
1717 * @param string $dir Absolute path.
1718 * @return void
1719 */
1720 public static function write_dir_protection( $dir ) {
1721 if ( ! is_dir( $dir ) ) {
1722 return;
1723 }
1724 $htaccess = $dir . '/.htaccess';
1725 if ( ! file_exists( $htaccess ) ) {
1726 self::put_file( $htaccess, self::htaccess_content() );
1727 }
1728 $web_config = $dir . '/web.config';
1729 if ( ! file_exists( $web_config ) ) {
1730 self::put_file( $web_config, self::web_config_content() );
1731 }
1732 $index = $dir . '/index.php';
1733 if ( ! file_exists( $index ) ) {
1734 self::put_file( $index, '<?php // Silence is golden.' );
1735 }
1736 }
1737
1738 /**
1739 * Create the base snippets dir with HTTP-deny protection files and an
1740 * empty manifest. Idempotent — safe to call on every write path.
1741 *
1742 * @since 0.0.5
1743 * @return void
1744 */
1745 public static function ensure_base_dir() {
1746 $base = self::base_dir();
1747 if ( ! is_dir( $base ) ) {
1748 wp_mkdir_p( $base );
1749 }
1750 self::write_dir_protection( $base );
1751
1752 // Doc-only nginx config snippet — admins on Nginx can paste it into
1753 // their server block, where `.htaccess` is inert.
1754 $nginx_path = $base . '/nginx.conf.example';
1755 if ( ! file_exists( $nginx_path ) ) {
1756 self::put_file( $nginx_path, self::nginx_config_content() );
1757 }
1758
1759 if ( ! file_exists( self::manifest_path() ) ) {
1760 self::save( array() );
1761 }
1762 }
1763
1764 /**
1765 * Ensure a snippet's storage dir exists and is HTTP-denied, minting a
1766 * randomized on-disk basename for new snippets.
1767 *
1768 * SECURITY: every write path (REST create, REST import, agent ability)
1769 * must route through here. A caller that mkdir's `snippet_dir()` itself
1770 * gets `resolve_dir_name()`'s slug fallback — a path guessable from the
1771 * (possibly attacker-supplied) slug, and directly fetchable on servers
1772 * where `.htaccess` is inert.
1773 *
1774 * @since 0.0.5
1775 * @param string $slug Validated snippet slug.
1776 * @param bool $is_new True when creating; false keeps the existing dir.
1777 * @return string On-disk directory basename — persist as the manifest `dir`.
1778 */
1779 public static function prepare_snippet_dir( $slug, $is_new ) {
1780 self::ensure_base_dir();
1781
1782 if ( $is_new ) {
1783 // Cache the minted name BEFORE resolving the path — otherwise
1784 // resolve_dir_name() falls back to the slug (no manifest entry yet).
1785 $dir_name = self::mint_storage_dir( $slug );
1786 self::set_dir_cache( $slug, $dir_name );
1787 } else {
1788 $dir_name = self::resolve_dir_name( $slug );
1789 }
1790
1791 $dir = self::base_dir() . '/' . $dir_name;
1792 if ( ! is_dir( $dir ) ) {
1793 wp_mkdir_p( $dir );
1794 }
1795 self::write_dir_protection( $dir );
1796
1797 return $dir_name;
1798 }
1799
1800 /**
1801 * Invalidate PHP opcache for a snippet file after rewrite.
1802 *
1803 * Critical on hosts with `opcache.validate_timestamps=0` — without this
1804 * call, `include` keeps executing the previously-compiled bytecode even
1805 * after the file content changes, so frontend never picks up snippet
1806 * edits until opcache is flushed manually.
1807 *
1808 * @since 0.0.5
1809 * @param string $filepath Absolute path to the file just written.
1810 * @return void
1811 */
1812 public static function invalidate_opcache( $filepath ) {
1813 if ( ! function_exists( 'opcache_invalidate' ) ) {
1814 return;
1815 }
1816 // Only PHP files get cached; skip JS/CSS to avoid noise.
1817 if ( substr( $filepath, -4 ) !== '.php' ) {
1818 return;
1819 }
1820 // `force = true` so we drop the entry even if its timestamp matches.
1821 @opcache_invalidate( $filepath, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPressVIPMinimum.Functions.RestrictedFunctions.opcache_opcache_invalidate -- drop stale bytecode so include() picks up the rewritten snippet; no WP equivalent.
1822 }
1823 }
1824