PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/utils.php +684 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,684 @@
1 +<?php
2 +/**
3 + * Shared utility functions used by both export.php and import.php.
4 + *
5 + * These helpers live in a namespace so they don't collide with global
6 + * functions of the same name declared by third-party plugins or
7 + * WordPress drop-ins. Generic names like parse_size() or normalize_path()
8 + * are guaranteed to clash sooner or later if they sit in the global
9 + * namespace, and more than one plugin on a WordPress.com site loads this
10 + * file.
11 + *
12 + * Consumers require this file when they need its helpers. Keep this file
13 + * limited to guarded function declarations: do not add I/O, hooks, mutable
14 + * global state, or eager class definitions here.
15 + *
16 + * The two str_* polyfills stay global on purpose: they backfill functions
17 + * unavailable before PHP 8.0, so callers reach them via the global namespace
18 + * without a use-statement.
19 + */
20 +
21 +// Polyfill for PHP versions before 8.0, which lack str_starts_with().
22 +namespace {
23 + if (!function_exists('str_starts_with')) {
24 + function str_starts_with(string $haystack, string $needle): bool {
25 + return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0;
26 + }
27 + }
28 +
29 + // Polyfill for PHP versions before 8.0, which lack str_contains().
30 + if (!function_exists('str_contains')) {
31 + function str_contains(string $haystack, string $needle): bool {
32 + return $needle === '' || strpos($haystack, $needle) !== false;
33 + }
34 + }
35 +}
36 +
37 +namespace WordPress\Reprint\Server {
38 +
39 +use InvalidArgumentException;
40 +use RuntimeException;
41 +
42 +// Every declaration below carries its own function_exists() guard, and the
43 +// guards are deliberately per-function rather than one block-wide check.
44 +//
45 +// Two plugins on the same site can each ship a copy of this package — wpcomsh
46 +// and Jetpack both do on WordPress.com — so one of them declares these
47 +// functions first and the other must not redeclare them. A single guard keyed
48 +// on one sentinel function would work only while both copies declare exactly
49 +// the same set: the moment one ships a helper the other lacks, the second copy
50 +// skips the whole block and the first call to that helper is a fatal.
51 +// Per-function guards degrade to "whoever loaded first wins the functions it
52 +// has, this copy supplies the rest" instead.
53 +//
54 +// The guards earn their keep when two copies are loaded: a monorepo checkout
55 +// that loads both packages/reprint-server/src/utils.php and the vendor/ mirror
56 +// of it, or a site still carrying reprint-exporter v0.1.47
57 +// under its old package name. That older copy declares its helpers in
58 +// WordPress\Reprint\Exporter, a namespace nothing here uses any more, so it
59 +// cannot reach these names at all — but the guards cost nothing and they
60 +// document the hazard.
61 +//
62 +// Function bodies stay unindented inside their guards, matching how the
63 +// bracketed namespace blocks in this file are written.
64 +
65 +if (!function_exists(__NAMESPACE__ . '\\generate_random_bytes')) {
66 +/**
67 + * Returns cryptographically secure random bytes on every supported PHP version.
68 + *
69 + * @param int $length Number of bytes to return.
70 + * @return string Random bytes.
71 + * @throws RuntimeException When the runtime has no secure random-byte source.
72 + */
73 +function generate_random_bytes(int $length): string
74 +{
75 + if (function_exists('random_bytes')) {
76 + return random_bytes($length);
77 + }
78 +
79 + if (function_exists('openssl_random_pseudo_bytes')) {
80 + $strong = false;
81 + $bytes = openssl_random_pseudo_bytes($length, $strong);
82 + if ($bytes !== false && $strong && strlen($bytes) === $length) {
83 + return $bytes;
84 + }
85 + }
86 +
87 + throw new RuntimeException('The PHP runtime has no cryptographically secure random-byte source.');
88 +}
89 +}
90 +
91 +if (!function_exists(__NAMESPACE__ . '\\integer_divide')) {
92 +/**
93 + * Divides two integers and rounds the result toward zero.
94 + *
95 + * @param int $dividend Number to divide.
96 + * @param int $divisor Number to divide by.
97 + * @return int Integer quotient.
98 + */
99 +function integer_divide(int $dividend, int $divisor): int
100 +{
101 + if (function_exists('intdiv')) {
102 + return intdiv($dividend, $divisor);
103 + }
104 +
105 + return intval($dividend / $divisor);
106 +}
107 +}
108 +
109 +if (!function_exists(__NAMESPACE__ . '\\build_pdo_dsn')) {
110 +/**
111 + * Builds a PDO DSN string from a WordPress DB_HOST value.
112 + *
113 + * WordPress's DB_HOST supports several non-standard formats that shared
114 + * hosts commonly use:
115 + * - "localhost" → standard hostname
116 + * - "db.host.com:3307" → hostname with port
117 + * - "localhost:/path/sock" → hostname with Unix socket
118 + * - "/path/to/mysql.sock" → bare Unix socket path
119 + * - "::1" → IPv6 address
120 + * - "[::1]" → bracketed IPv6
121 + * - "[::1]:3306" → bracketed IPv6 with port
122 + * - "[::1]:/path/to/socket" → bracketed IPv6 with Unix socket
123 + *
124 + * PDO needs these broken out into separate DSN parameters (host, port,
125 + * unix_socket), so we parse the value the same way WordPress core does.
126 + *
127 + * @param string $db_host Raw DB_HOST value.
128 + * @param string $db_name Database name.
129 + * @return string PDO DSN string.
130 + */
131 +function build_pdo_dsn(string $db_host, string $db_name): string
132 +{
133 + $socket = '';
134 + $host = $db_host;
135 + $port = '';
136 +
137 + if (str_starts_with($db_host, '/') && file_exists($db_host)) {
138 + // Bare socket path: "/var/run/mysqld/mysqld.sock"
139 + $socket = $db_host;
140 + $host = '';
141 + } elseif (
142 + str_starts_with($db_host, '[') &&
143 + ($bracket_end = strpos($db_host, ']')) !== false
144 + ) {
145 + // Bracketed IPv6: "[::1]", "[::1]:3306", "[::1]:/path/to/socket"
146 + $host = substr($db_host, 1, $bracket_end - 1);
147 + $after = substr($db_host, $bracket_end + 1);
148 + $candidate_socket = str_starts_with($after, ':/') ? substr($after, 1) : '';
149 + if ($candidate_socket !== '' && file_exists($candidate_socket)) {
150 + $socket = $candidate_socket;
151 + } elseif (str_starts_with($after, ':')) {
152 + $port = substr($after, 1);
153 + }
154 + } elseif (($socket_pos = strpos($db_host, ':/')) !== false) {
155 + // "host:/path/to/socket" — check before general colon split
156 + // to avoid misinterpreting IPv6 addresses as host:port
157 + $candidate_socket = substr($db_host, $socket_pos + 1);
158 + if (file_exists($candidate_socket)) {
159 + $host = substr($db_host, 0, $socket_pos);
160 + $socket = $candidate_socket;
161 + } elseif (substr_count($db_host, ':') === 1) {
162 + // Single colon but not a socket — treat as host:port
163 + [$host, $port] = explode(':', $db_host, 2);
164 + }
165 + } elseif (
166 + str_contains($db_host, ':') &&
167 + substr_count($db_host, ':') === 1
168 + ) {
169 + // Exactly one colon: "host:port" — not IPv6
170 + [$host, $port] = explode(':', $db_host, 2);
171 + }
172 + // Otherwise (multiple colons, no socket marker): bare IPv6 like "::1"
173 + // — $host stays as the full value.
174 +
175 + if ($socket !== '') {
176 + return "mysql:unix_socket={$socket};dbname={$db_name};charset=utf8mb4";
177 + }
178 +
179 + $dsn = "mysql:host={$host}";
180 + if ($port !== '') {
181 + $dsn .= ";port={$port}";
182 + }
183 + $dsn .= ";dbname={$db_name};charset=utf8mb4";
184 + return $dsn;
185 +}
186 +}
187 +
188 +if (!function_exists(__NAMESPACE__ . '\\parse_size')) {
189 +/**
190 + * Parse a human-readable size string (e.g. "16M", "1G", "512K") into bytes.
191 + * Accepts plain integers as well.
192 + */
193 +function parse_size(string $value): int
194 +{
195 + $value = trim($value);
196 + if (!preg_match('/^(\d+(?:\.\d+)?)\s*([KMGkmg])?[Bb]?$/', $value, $m)) {
197 + throw new InvalidArgumentException(
198 + "Invalid size value: '{$value}'. Use a number optionally followed by K, M, or G (e.g. 64M)."
199 + );
200 + }
201 + $num = (float) $m[1];
202 + $suffix = strtoupper($m[2] ?? "");
203 + switch ($suffix) {
204 + case "K":
205 + return (int) ($num * 1024);
206 + case "M":
207 + return (int) ($num * 1024 * 1024);
208 + case "G":
209 + return (int) ($num * 1024 * 1024 * 1024);
210 + default:
211 + return (int) $num;
212 + }
213 +}
214 +}
215 +
216 +if (!function_exists(__NAMESPACE__ . '\\json_encode_or_throw')) {
217 +/**
218 + * Throws on json_encode failure instead of returning false.
219 + *
220 + * Do NOT use inside error/shutdown handlers — those need hardcoded fallback strings.
221 + */
222 +function json_encode_or_throw($value, int $flags = 0): string
223 +{
224 + $json = json_encode($value, $flags);
225 + if ($json === false) {
226 + throw new RuntimeException("json_encode failed: " . json_last_error_msg());
227 + }
228 + return $json;
229 +}
230 +}
231 +
232 +if (!function_exists(__NAMESPACE__ . '\\normalize_path')) {
233 +/**
234 + * Resolve ".." and "." segments in a path without touching the filesystem.
235 + *
236 + * Unlike realpath(), this works on paths that don't exist yet.
237 + */
238 +function normalize_path(string $path): string
239 +{
240 + $parts = explode("/", $path);
241 + $resolved = [];
242 + foreach ($parts as $part) {
243 + if ($part === "" || $part === ".") {
244 + continue;
245 + }
246 + if ($part === "..") {
247 + array_pop($resolved);
248 + } else {
249 + $resolved[] = $part;
250 + }
251 + }
252 + return "/" . implode("/", $resolved);
253 +}
254 +}
255 +
256 +if (!function_exists(__NAMESPACE__ . '\\trim_right_slash')) {
257 +/**
258 + * Removes trailing slashes without changing the filesystem root into an empty path.
259 + *
260 + * Unlike rtrim($path, '/'), this returns `/` for both the filesystem root and
261 + * an empty input. It only changes the lexical spelling; it does not validate
262 + * the path or resolve dot segments and symlinks.
263 + *
264 + * Examples:
265 + *
266 + * trim_right_slash('/srv/site///'); // '/srv/site'
267 + * trim_right_slash('/'); // '/'
268 + * trim_right_slash(''); // '/'
269 + *
270 + * @param string $path Path whose trailing slashes to remove.
271 + * @return string A path without trailing slashes, or `/` for the filesystem root.
272 + */
273 +function trim_right_slash(string $path): string
274 +{
275 + return rtrim($path, '/') ?: '/';
276 +}
277 +}
278 +
279 +if (!function_exists(__NAMESPACE__ . '\\realpath_with_missing_tail')) {
280 +/**
281 + * Canonicalizes an absolute path through the nearest ancestor realpath() can resolve.
282 + *
283 + * The final components need not exist. The function resolves a real ancestor,
284 + * then appends the missing components without creating them. A broken symlink
285 + * cannot be resolved safely, so its normalized lexical spelling is retained.
286 + *
287 + * Examples:
288 + *
289 + * realpath_with_missing_tail('/srv/site');
290 + * // '/srv/site' when /srv/site exists
291 + *
292 + * realpath_with_missing_tail('/srv/site/state/push');
293 + * // '/srv/site/state/push' when /srv/site exists but state/push do not
294 + *
295 + * realpath_with_missing_tail('/links/site/state');
296 + * // '/srv/site/state' when /links/site is a symlink to /srv/site
297 + *
298 + * This does not create, remove, or otherwise modify filesystem entries.
299 + *
300 + * @throws InvalidArgumentException When $absolute_path is not absolute.
301 + */
302 +function realpath_with_missing_tail(string $absolute_path): string
303 +{
304 + if ($absolute_path === '' || $absolute_path[0] !== '/') {
305 + throw new InvalidArgumentException('Path must be absolute: ' . $absolute_path);
306 + }
307 +
308 + $normalized_path = normalize_path($absolute_path);
309 + $missing_components = [];
310 + $existing_ancestor = $normalized_path;
311 + $canonical_existing_ancestor = realpath($existing_ancestor);
312 +
313 + while ($canonical_existing_ancestor === false) {
314 + // Keep a broken symlink lexical: resolving past it would change what a
315 + // future replacement of that link means.
316 + if (is_link($existing_ancestor)) {
317 + return $normalized_path;
318 + }
319 +
320 + $parent = dirname($existing_ancestor);
321 + if ($parent === $existing_ancestor) {
322 + return $normalized_path;
323 + }
324 + array_unshift($missing_components, basename($existing_ancestor));
325 + $existing_ancestor = $parent;
326 + $canonical_existing_ancestor = realpath($existing_ancestor);
327 + }
328 +
329 + if ($missing_components === []) {
330 + return normalize_path($canonical_existing_ancestor);
331 + }
332 +
333 + return normalize_path(
334 + $canonical_existing_ancestor . '/' . implode('/', $missing_components)
335 + );
336 +}
337 +}
338 +
339 +if (!function_exists(__NAMESPACE__ . '\\normalize_excluded_paths')) {
340 +/**
341 + * Normalizes document-root-relative excluded paths.
342 + *
343 + * Rejects non-string, empty, absolute, NUL-containing, backslash-containing,
344 + * and empty/dot/parent-component paths, then sorts and deduplicates them.
345 + *
346 + * @param string[] $excluded_paths Paths which a push must not change.
347 + * @phpstan-param array<mixed> $excluded_paths
348 + * @return list<string> Validated excluded paths in bytewise order.
349 + */
350 +function normalize_excluded_paths(array $excluded_paths): array
351 +{
352 + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- These validation exceptions are never rendered, and arbitrary path bytes are represented as base64.
353 + $normalized_excluded_paths = [];
354 + foreach ($excluded_paths as $path) {
355 + if (!is_string($path)) {
356 + throw new InvalidArgumentException('Each excluded path must be a string; observed ' . gettype($path) . '.');
357 + }
358 + if ($path !== '' && $path[0] === '/') {
359 + throw new InvalidArgumentException('Excluded path must be document-root-relative: ' . base64_encode($path) . '.');
360 + }
361 + assert_valid_relative_path($path, 'Excluded path');
362 + $normalized_excluded_paths[] = $path;
363 + }
364 + sort($normalized_excluded_paths, SORT_STRING);
365 + $normalized_excluded_paths = array_values(array_unique($normalized_excluded_paths));
366 + if (count($normalized_excluded_paths) > 100) {
367 + throw new InvalidArgumentException(
368 + 'Push supports at most 100 excluded paths; received '
369 + . count($normalized_excluded_paths)
370 + . ' after normalization.'
371 + );
372 + }
373 + return $normalized_excluded_paths;
374 +}
375 +}
376 +
377 +if (!function_exists(__NAMESPACE__ . '\\assert_valid_relative_path')) {
378 +/**
379 + * Validates a document-root-relative path carried as raw bytes.
380 + *
381 + * A valid path has one or more slash-delimited components. It cannot be
382 + * absolute, use Windows separators, include a NUL byte, or contain empty,
383 + * current-directory, or parent-directory components. It deliberately does
384 + * not trim whitespace: spaces and other non-reserved bytes are valid file
385 + * name bytes.
386 + *
387 + * Examples:
388 + *
389 + * assert_valid_relative_path('wp-content/plugins', 'Excluded path');
390 + * assert_valid_relative_path('index.php', 'Document-root-relative path');
391 + *
392 + * @param string $path Raw path bytes to validate.
393 + * @param string $label Human-readable name at the start of validation errors.
394 + * @throws InvalidArgumentException When the path has a reserved form.
395 + */
396 +function assert_valid_relative_path(string $path, string $label): void
397 +{
398 + if ($path === '') {
399 + throw new InvalidArgumentException("{$label} must not be empty.");
400 + }
401 + if ($path[0] === '/') {
402 + throw new InvalidArgumentException("{$label} must not be absolute: " . base64_encode($path) . '.');
403 + }
404 + if (strpos($path, "\0") !== false) {
405 + throw new InvalidArgumentException("{$label} must not contain a NUL byte: " . base64_encode($path) . '.');
406 + }
407 + if (strpos($path, '\\') !== false) {
408 + throw new InvalidArgumentException("{$label} must not contain a backslash: " . base64_encode($path) . '.');
409 + }
410 + foreach (explode('/', $path) as $component) {
411 + if ($component === '') {
412 + throw new InvalidArgumentException("{$label} must not contain an empty component: " . base64_encode($path) . '.');
413 + }
414 + if ($component === '.') {
415 + throw new InvalidArgumentException("{$label} must not contain a dot component: " . base64_encode($path) . '.');
416 + }
417 + if ($component === '..') {
418 + throw new InvalidArgumentException("{$label} must not contain a parent component: " . base64_encode($path) . '.');
419 + }
420 + }
421 +}
422 +}
423 +
424 +// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
425 +
426 +if (!function_exists(__NAMESPACE__ . '\\path_is_same_as_or_descendant_of')) {
427 +/**
428 + * Indicates whether a candidate path is the same as or a descendant of an
429 + * ancestor.
430 + *
431 + * Either argument may be a list. The result is true when any candidate-and-
432 + * ancestor pair matches. The filesystem root matches every absolute path and
433 + * cannot use the normal ancestor-plus-slash prefix because that would produce
434 + * `//`.
435 + *
436 + * Examples:
437 + *
438 + * path_is_same_as_or_descendant_of('/srv/site', '/srv/site'); // true
439 + * path_is_same_as_or_descendant_of('/srv/site/wp-content', '/srv/site'); // true
440 + * path_is_same_as_or_descendant_of('/srv/site-old', '/srv/site'); // false
441 + * path_is_same_as_or_descendant_of('/', '/'); // true
442 + *
443 + * @param string|list<string> $path Candidate path or paths.
444 + * @param string|list<string> $ancestor Ancestor path or paths.
445 + * @return bool Whether a candidate is the same as or a descendant of an
446 + * ancestor.
447 + * @throws InvalidArgumentException If either scalar value is not a string.
448 + */
449 +function path_is_same_as_or_descendant_of($path, $ancestor): bool
450 +{
451 + if (is_array($path)) {
452 + foreach ($path as $candidate_path) {
453 + if (path_is_same_as_or_descendant_of($candidate_path, $ancestor)) {
454 + return true;
455 + }
456 + }
457 + return false;
458 + }
459 + if (is_array($ancestor)) {
460 + foreach ($ancestor as $candidate_ancestor) {
461 + if (path_is_same_as_or_descendant_of($path, $candidate_ancestor)) {
462 + return true;
463 + }
464 + }
465 + return false;
466 + }
467 + if (!is_string($path) || !is_string($ancestor)) {
468 + throw new InvalidArgumentException('Path containment expects strings or lists of strings.');
469 + }
470 + if ($ancestor === "/") {
471 + return str_starts_with($path, "/");
472 + }
473 + return $path === $ancestor || str_starts_with($path, $ancestor . "/");
474 +}
475 +}
476 +
477 +if (!function_exists(__NAMESPACE__ . '\\path_is_descendant_of')) {
478 +/**
479 + * Indicates whether a candidate path is a descendant of an ancestor.
480 + *
481 + * Either argument may be a list. The result is true when any candidate-and-
482 + * ancestor pair has a component-boundary match below the ancestor. Unlike
483 + * path_is_same_as_or_descendant_of(), equal paths do not match. The
484 + * filesystem root contains every absolute descendant, but not itself.
485 + *
486 + * Examples:
487 + *
488 + * path_is_descendant_of('/srv/site/wp-content', '/srv/site'); // true
489 + * path_is_descendant_of('/srv/site', '/srv/site'); // false
490 + * path_is_descendant_of('/srv/site-old', '/srv/site'); // false
491 + * path_is_descendant_of('/wp-content', '/'); // true
492 + * path_is_descendant_of('/', '/'); // false
493 + *
494 + * @param string|list<string> $path Candidate path or paths.
495 + * @param string|list<string> $ancestor Ancestor path or paths.
496 + * @return bool Whether a candidate is a descendant of an ancestor.
497 + * @throws InvalidArgumentException If either scalar value is not a string.
498 + */
499 +function path_is_descendant_of($path, $ancestor): bool
500 +{
501 + if (is_array($path)) {
502 + foreach ($path as $candidate_path) {
503 + if (path_is_descendant_of($candidate_path, $ancestor)) {
504 + return true;
505 + }
506 + }
507 + return false;
508 + }
509 + if (is_array($ancestor)) {
510 + foreach ($ancestor as $candidate_ancestor) {
511 + if (path_is_descendant_of($path, $candidate_ancestor)) {
512 + return true;
513 + }
514 + }
515 + return false;
516 + }
517 + if (!path_is_same_as_or_descendant_of($path, $ancestor)) {
518 + return false;
519 + }
520 + return $path !== $ancestor;
521 +}
522 +}
523 +
524 +if (!function_exists(__NAMESPACE__ . '\\path_remainder_under')) {
525 +/**
526 + * Returns the remainder of $path underneath $prefix.
527 + *
528 + * An exact match returns an empty string. A descendant returns the remainder
529 + * beginning with "/". A path outside $prefix returns null.
530 + */
531 +function path_remainder_under(string $path, string $prefix): ?string
532 +{
533 + $path = rtrim($path, "/");
534 + $prefix = rtrim($prefix, "/");
535 +
536 + if ($path === $prefix) {
537 + return "";
538 + }
539 +
540 + if (str_starts_with($path, $prefix . "/")) {
541 + return substr($path, strlen($prefix));
542 + }
543 +
544 + return null;
545 +}
546 +}
547 +
548 +if (!function_exists(__NAMESPACE__ . '\\relative_path_under')) {
549 +/**
550 + * Returns a path relative to a slash-delimited root, or null when it is not
551 + * equal to or below that root.
552 + *
553 + * Use this when a caller needs a path for a root-relative field. It performs
554 + * the component-boundary test and removes the separating slash in one step,
555 + * rather than letting a byte-offset slice treat `/srv/site-old` as below
556 + * `/srv/site`.
557 + *
558 + * Examples:
559 + *
560 + * relative_path_under('/srv/site/wp-content', '/srv/site'); // 'wp-content'
561 + * relative_path_under('/srv/site', '/srv/site'); // ''
562 + * relative_path_under('/srv/site-old', '/srv/site'); // null
563 + * relative_path_under('/wp-content', '/'); // 'wp-content'
564 + * relative_path_under('wp-content/plugins', ''); // 'wp-content/plugins'
565 + *
566 + * Trailing slashes do not change the result. This is a lexical operation: it
567 + * does not resolve dot segments or symlinks, and it also accepts relative
568 + * slash-delimited paths. An empty root contains every relative path, but no
569 + * absolute path.
570 + *
571 + * @param string $path Candidate path to make relative.
572 + * @param string $root Root that must contain the candidate path.
573 + * @return string|null A path without a leading slash, an empty string for an
574 + * exact match, or null when the path is outside the root.
575 + */
576 +function relative_path_under(string $path, string $root): ?string
577 +{
578 + if ($root === "") {
579 + return str_starts_with($path, "/") ? null : rtrim($path, "/");
580 + }
581 + $remainder = path_remainder_under($path, $root);
582 + return $remainder === null ? null : ltrim($remainder, "/");
583 +}
584 +}
585 +
586 +if (!function_exists(__NAMESPACE__ . '\\assert_valid_path')) {
587 +/**
588 + * Validates that a path is a non-empty absolute string without NUL bytes
589 + * or dot-segments (. or ..).
590 + *
591 + * Useful anywhere untrusted or remote paths need to be checked before
592 + * use — both the exporter (directory config) and the importer (remote
593 + * paths from the server) share this validation.
594 + *
595 + * @param string $path The path to validate.
596 + * @param string $label Human-readable label for error messages (e.g. "directory", "remote path").
597 + * @throws InvalidArgumentException When the path fails any check.
598 + */
599 +function assert_valid_path(string $path, string $label = "path"): void
600 +{
601 + $path = trim($path);
602 + if ($path === "") {
603 + throw new InvalidArgumentException("{$label} must be a non-empty string");
604 + }
605 + if ($path[0] !== "/") {
606 + throw new InvalidArgumentException("{$label} must be an absolute path: {$path}");
607 + }
608 + if (strpos($path, "\0") !== false) {
609 + throw new InvalidArgumentException("{$label} must not contain NUL bytes");
610 + }
611 + foreach (explode("/", $path) as $segment) {
612 + if ($segment === "." || $segment === "..") {
613 + throw new InvalidArgumentException(
614 + "{$label} must not contain dot-segments (. or ..): {$path}"
615 + );
616 + }
617 + }
618 +}
619 +}
620 +
621 +// ---------------------------------------------------------------------------
622 +// Vendored from wp-php-toolkit/filesystem.
623 +//
624 +// This is a copy of WordPress\Filesystem\wp_join_unix_paths(), kept in sync by
625 +// hand. Do not "fix" it by importing the original: reprint-server must require
626 +// nothing but PHP.
627 +//
628 +// Consumers vendor this package into Composer autoloaders that are not scoped
629 +// to one plugin — Jetpack's is the one that bites. It folds every installed
630 +// package's psr-4, classmap and files entries into site-global manifests that
631 +// arbitrate class and function names, by version, across every plugin on the
632 +// site. Requiring wp-php-toolkit/filesystem would publish WordPress\Filesystem
633 +// site-wide, where it would be arbitrated against the copy WordPress Importer
634 +// already ships through data-liberation. Two copies of one namespace in one
635 +// version-arbitrated manifest is what produced Automattic/jetpack#51027.
636 +//
637 +// WordPress core's path_join() is not a substitute. It takes two arguments
638 +// rather than being variadic, does not collapse duplicate slashes, and returns
639 +// the second argument alone when that is absolute, discarding the base. Its
640 +// path_is_absolute() check also calls realpath() plus a stream-wrapper lookup,
641 +// and class-file-index-processor.php calls this once per directory entry in
642 +// the file walk.
643 +// ---------------------------------------------------------------------------
644 +if (!function_exists(__NAMESPACE__ . '\\wp_join_unix_paths')) {
645 +/**
646 + * Joins path segments into one Unix path, collapsing duplicate slashes.
647 + *
648 + * Empty segments are skipped. A leading slash on the first non-empty segment
649 + * is preserved. Trailing slashes are left as the caller wrote them.
650 + *
651 + * Examples:
652 + *
653 + * wp_join_unix_paths('/srv/site', 'wp-content'); // '/srv/site/wp-content'
654 + * wp_join_unix_paths('/srv/site/', '/uploads'); // '/srv/site/uploads'
655 + * wp_join_unix_paths('', 'wp-content', ''); // 'wp-content'
656 + *
657 + * @param string ...$path_segments Segments to join.
658 + * @return string The joined path.
659 + */
660 +function wp_join_unix_paths(...$path_segments)
661 +{
662 + $input_starts_with_slash = null;
663 +
664 + $paths = [];
665 + foreach ($path_segments as $path_segment) {
666 + if ($path_segment !== '') {
667 + $paths[] = $path_segment;
668 + if ($input_starts_with_slash === null) {
669 + $input_starts_with_slash = strncmp($path_segment, '/', strlen('/')) === 0;
670 + }
671 + }
672 + }
673 + $path = implode('/', $paths);
674 +
675 + $result = preg_replace('#/+#', '/', $path);
676 + if ($input_starts_with_slash && strncmp($result, '/', strlen('/')) !== 0) {
677 + $result = '/' . $result;
678 + }
679 +
680 + return $result;
681 +}
682 +}
683 +
684 +}