$excluded_paths * @return list Validated excluded paths in bytewise order. */ function normalize_excluded_paths(array $excluded_paths): array { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- These validation exceptions are never rendered, and arbitrary path bytes are represented as base64. $normalized_excluded_paths = []; foreach ($excluded_paths as $path) { if (!is_string($path)) { throw new InvalidArgumentException('Each excluded path must be a string; observed ' . gettype($path) . '.'); } if ($path !== '' && $path[0] === '/') { throw new InvalidArgumentException('Excluded path must be document-root-relative: ' . base64_encode($path) . '.'); } assert_valid_relative_path($path, 'Excluded path'); $normalized_excluded_paths[] = $path; } sort($normalized_excluded_paths, SORT_STRING); $normalized_excluded_paths = array_values(array_unique($normalized_excluded_paths)); if (count($normalized_excluded_paths) > 100) { throw new InvalidArgumentException( 'Push supports at most 100 excluded paths; received ' . count($normalized_excluded_paths) . ' after normalization.' ); } return $normalized_excluded_paths; } } if (!function_exists(__NAMESPACE__ . '\\assert_valid_relative_path')) { /** * Validates a document-root-relative path carried as raw bytes. * * A valid path has one or more slash-delimited components. It cannot be * absolute, use Windows separators, include a NUL byte, or contain empty, * current-directory, or parent-directory components. It deliberately does * not trim whitespace: spaces and other non-reserved bytes are valid file * name bytes. * * Examples: * * assert_valid_relative_path('wp-content/plugins', 'Excluded path'); * assert_valid_relative_path('index.php', 'Document-root-relative path'); * * @param string $path Raw path bytes to validate. * @param string $label Human-readable name at the start of validation errors. * @throws InvalidArgumentException When the path has a reserved form. */ function assert_valid_relative_path(string $path, string $label): void { if ($path === '') { throw new InvalidArgumentException("{$label} must not be empty."); } if ($path[0] === '/') { throw new InvalidArgumentException("{$label} must not be absolute: " . base64_encode($path) . '.'); } if (strpos($path, "\0") !== false) { throw new InvalidArgumentException("{$label} must not contain a NUL byte: " . base64_encode($path) . '.'); } if (strpos($path, '\\') !== false) { throw new InvalidArgumentException("{$label} must not contain a backslash: " . base64_encode($path) . '.'); } foreach (explode('/', $path) as $component) { if ($component === '') { throw new InvalidArgumentException("{$label} must not contain an empty component: " . base64_encode($path) . '.'); } if ($component === '.') { throw new InvalidArgumentException("{$label} must not contain a dot component: " . base64_encode($path) . '.'); } if ($component === '..') { throw new InvalidArgumentException("{$label} must not contain a parent component: " . base64_encode($path) . '.'); } } } } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped if (!function_exists(__NAMESPACE__ . '\\path_is_same_as_or_descendant_of')) { /** * Indicates whether a candidate path is the same as or a descendant of an * ancestor. * * Either argument may be a list. The result is true when any candidate-and- * ancestor pair matches. The filesystem root matches every absolute path and * cannot use the normal ancestor-plus-slash prefix because that would produce * `//`. * * Examples: * * path_is_same_as_or_descendant_of('/srv/site', '/srv/site'); // true * path_is_same_as_or_descendant_of('/srv/site/wp-content', '/srv/site'); // true * path_is_same_as_or_descendant_of('/srv/site-old', '/srv/site'); // false * path_is_same_as_or_descendant_of('/', '/'); // true * * @param string|list $path Candidate path or paths. * @param string|list $ancestor Ancestor path or paths. * @return bool Whether a candidate is the same as or a descendant of an * ancestor. * @throws InvalidArgumentException If either scalar value is not a string. */ function path_is_same_as_or_descendant_of($path, $ancestor): bool { if (is_array($path)) { foreach ($path as $candidate_path) { if (path_is_same_as_or_descendant_of($candidate_path, $ancestor)) { return true; } } return false; } if (is_array($ancestor)) { foreach ($ancestor as $candidate_ancestor) { if (path_is_same_as_or_descendant_of($path, $candidate_ancestor)) { return true; } } return false; } if (!is_string($path) || !is_string($ancestor)) { throw new InvalidArgumentException('Path containment expects strings or lists of strings.'); } if ($ancestor === "/") { return str_starts_with($path, "/"); } return $path === $ancestor || str_starts_with($path, $ancestor . "/"); } } if (!function_exists(__NAMESPACE__ . '\\path_is_descendant_of')) { /** * Indicates whether a candidate path is a descendant of an ancestor. * * Either argument may be a list. The result is true when any candidate-and- * ancestor pair has a component-boundary match below the ancestor. Unlike * path_is_same_as_or_descendant_of(), equal paths do not match. The * filesystem root contains every absolute descendant, but not itself. * * Examples: * * path_is_descendant_of('/srv/site/wp-content', '/srv/site'); // true * path_is_descendant_of('/srv/site', '/srv/site'); // false * path_is_descendant_of('/srv/site-old', '/srv/site'); // false * path_is_descendant_of('/wp-content', '/'); // true * path_is_descendant_of('/', '/'); // false * * @param string|list $path Candidate path or paths. * @param string|list $ancestor Ancestor path or paths. * @return bool Whether a candidate is a descendant of an ancestor. * @throws InvalidArgumentException If either scalar value is not a string. */ function path_is_descendant_of($path, $ancestor): bool { if (is_array($path)) { foreach ($path as $candidate_path) { if (path_is_descendant_of($candidate_path, $ancestor)) { return true; } } return false; } if (is_array($ancestor)) { foreach ($ancestor as $candidate_ancestor) { if (path_is_descendant_of($path, $candidate_ancestor)) { return true; } } return false; } if (!path_is_same_as_or_descendant_of($path, $ancestor)) { return false; } return $path !== $ancestor; } } if (!function_exists(__NAMESPACE__ . '\\path_remainder_under')) { /** * Returns the remainder of $path underneath $prefix. * * An exact match returns an empty string. A descendant returns the remainder * beginning with "/". A path outside $prefix returns null. */ function path_remainder_under(string $path, string $prefix): ?string { $path = rtrim($path, "/"); $prefix = rtrim($prefix, "/"); if ($path === $prefix) { return ""; } if (str_starts_with($path, $prefix . "/")) { return substr($path, strlen($prefix)); } return null; } } if (!function_exists(__NAMESPACE__ . '\\relative_path_under')) { /** * Returns a path relative to a slash-delimited root, or null when it is not * equal to or below that root. * * Use this when a caller needs a path for a root-relative field. It performs * the component-boundary test and removes the separating slash in one step, * rather than letting a byte-offset slice treat `/srv/site-old` as below * `/srv/site`. * * Examples: * * relative_path_under('/srv/site/wp-content', '/srv/site'); // 'wp-content' * relative_path_under('/srv/site', '/srv/site'); // '' * relative_path_under('/srv/site-old', '/srv/site'); // null * relative_path_under('/wp-content', '/'); // 'wp-content' * relative_path_under('wp-content/plugins', ''); // 'wp-content/plugins' * * Trailing slashes do not change the result. This is a lexical operation: it * does not resolve dot segments or symlinks, and it also accepts relative * slash-delimited paths. An empty root contains every relative path, but no * absolute path. * * @param string $path Candidate path to make relative. * @param string $root Root that must contain the candidate path. * @return string|null A path without a leading slash, an empty string for an * exact match, or null when the path is outside the root. */ function relative_path_under(string $path, string $root): ?string { if ($root === "") { return str_starts_with($path, "/") ? null : rtrim($path, "/"); } $remainder = path_remainder_under($path, $root); return $remainder === null ? null : ltrim($remainder, "/"); } } if (!function_exists(__NAMESPACE__ . '\\assert_valid_path')) { /** * Validates that a path is a non-empty absolute string without NUL bytes * or dot-segments (. or ..). * * Useful anywhere untrusted or remote paths need to be checked before * use — both the exporter (directory config) and the importer (remote * paths from the server) share this validation. * * @param string $path The path to validate. * @param string $label Human-readable label for error messages (e.g. "directory", "remote path"). * @throws InvalidArgumentException When the path fails any check. */ function assert_valid_path(string $path, string $label = "path"): void { $path = trim($path); if ($path === "") { throw new InvalidArgumentException("{$label} must be a non-empty string"); } if ($path[0] !== "/") { throw new InvalidArgumentException("{$label} must be an absolute path: {$path}"); } if (strpos($path, "\0") !== false) { throw new InvalidArgumentException("{$label} must not contain NUL bytes"); } foreach (explode("/", $path) as $segment) { if ($segment === "." || $segment === "..") { throw new InvalidArgumentException( "{$label} must not contain dot-segments (. or ..): {$path}" ); } } } } // --------------------------------------------------------------------------- // Vendored from wp-php-toolkit/filesystem. // // This is a copy of WordPress\Filesystem\wp_join_unix_paths(), kept in sync by // hand. Do not "fix" it by importing the original: reprint-server must require // nothing but PHP. // // Consumers vendor this package into Composer autoloaders that are not scoped // to one plugin — Jetpack's is the one that bites. It folds every installed // package's psr-4, classmap and files entries into site-global manifests that // arbitrate class and function names, by version, across every plugin on the // site. Requiring wp-php-toolkit/filesystem would publish WordPress\Filesystem // site-wide, where it would be arbitrated against the copy WordPress Importer // already ships through data-liberation. Two copies of one namespace in one // version-arbitrated manifest is what produced Automattic/jetpack#51027. // // WordPress core's path_join() is not a substitute. It takes two arguments // rather than being variadic, does not collapse duplicate slashes, and returns // the second argument alone when that is absolute, discarding the base. Its // path_is_absolute() check also calls realpath() plus a stream-wrapper lookup, // and class-file-index-processor.php calls this once per directory entry in // the file walk. // --------------------------------------------------------------------------- if (!function_exists(__NAMESPACE__ . '\\wp_join_unix_paths')) { /** * Joins path segments into one Unix path, collapsing duplicate slashes. * * Empty segments are skipped. A leading slash on the first non-empty segment * is preserved. Trailing slashes are left as the caller wrote them. * * Examples: * * wp_join_unix_paths('/srv/site', 'wp-content'); // '/srv/site/wp-content' * wp_join_unix_paths('/srv/site/', '/uploads'); // '/srv/site/uploads' * wp_join_unix_paths('', 'wp-content', ''); // 'wp-content' * * @param string ...$path_segments Segments to join. * @return string The joined path. */ function wp_join_unix_paths(...$path_segments) { $input_starts_with_slash = null; $paths = []; foreach ($path_segments as $path_segment) { if ($path_segment !== '') { $paths[] = $path_segment; if ($input_starts_with_slash === null) { $input_starts_with_slash = strncmp($path_segment, '/', strlen('/')) === 0; } } } $path = implode('/', $paths); $result = preg_replace('#/+#', '/', $path); if ($input_starts_with_slash && strncmp($result, '/', strlen('/')) !== 0) { $result = '/' . $result; } return $result; } } }