PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
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 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / utils.php

utils.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/utils.php

685 lines 25.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
685