1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Filesystem; 13 14use Symfony\Component\Filesystem\Exception\InvalidArgumentException; 15use Symfony\Component\Filesystem\Exception\RuntimeException; 16 17/** 18 * Contains utility methods for handling path strings. 19 * 20 * The methods in this class are able to deal with both UNIX and Windows paths 21 * with both forward and backward slashes. All methods return normalized parts 22 * containing only forward slashes and no excess "." and ".." segments. 23 * 24 * @author Bernhard Schussek <bschussek@gmail.com> 25 * @author Thomas Schulz <mail@king2500.net> 26 * @author Théo Fidry <theo.fidry@gmail.com> 27 */ 28final class Path 29{ 30 /** 31 * The number of buffer entries that triggers a cleanup operation. 32 */ 33 private const CLEANUP_THRESHOLD = 1250; 34 35 /** 36 * The buffer size after the cleanup operation. 37 */ 38 private const CLEANUP_SIZE = 1000; 39 40 /** 41 * Buffers input/output of {@link canonicalize()}. 42 * 43 * @var array<string, string> 44 */ 45 private static $buffer = []; 46 47 /** 48 * @var int 49 */ 50 private static $bufferSize = 0; 51 52 /** 53 * Canonicalizes the given path. 54 * 55 * During normalization, all slashes are replaced by forward slashes ("/"). 56 * Furthermore, all "." and ".." segments are removed as far as possible. 57 * ".." segments at the beginning of relative paths are not removed. 58 * 59 * ```php 60 * echo Path::canonicalize("\symfony\puli\..\css\style.css"); 61 * // => /symfony/css/style.css 62 * 63 * echo Path::canonicalize("../css/./style.css"); 64 * // => ../css/style.css 65 * ``` 66 * 67 * This method is able to deal with both UNIX and Windows paths. 68 */ 69 public static function canonicalize(string $path): string 70 { 71 if ('' === $path) { 72 return ''; 73 } 74 75 // This method is called by many other methods in this class. Buffer 76 // the canonicalized paths to make up for the severe performance 77 // decrease. 78 if (isset(self::$buffer[$path])) { 79 return self::$buffer[$path]; 80 } 81 82 // Replace "~" with user's home directory. 83 if ('~' === $path[0]) { 84 $path = self::getHomeDirectory().mb_substr($path, 1); 85 } 86 87 $path = self::normalize($path); 88 89 [$root, $pathWithoutRoot] = self::split($path); 90 91 $canonicalParts = self::findCanonicalParts($root, $pathWithoutRoot); 92 93 // Add the root directory again 94 self::$buffer[$path] = $canonicalPath = $root.implode('/', $canonicalParts); 95 ++self::$bufferSize; 96 97 // Clean up regularly to prevent memory leaks 98 if (self::$bufferSize > self::CLEANUP_THRESHOLD) { 99 self::$buffer = \array_slice(self::$buffer, -self::CLEANUP_SIZE, null, true); 100 self::$bufferSize = self::CLEANUP_SIZE; 101 } 102 103 return $canonicalPath; 104 } 105 106 /** 107 * Normalizes the given path. 108 * 109 * During normalization, all slashes are replaced by forward slashes ("/"). 110 * Contrary to {@link canonicalize()}, this method does not remove invalid 111 * or dot path segments. Consequently, it is much more efficient and should 112 * be used whenever the given path is known to be a valid, absolute system 113 * path. 114 * 115 * This method is able to deal with both UNIX and Windows paths. 116 */ 117 public static function normalize(string $path): string 118 { 119 return str_replace('\\', '/', $path); 120 } 121 122 /** 123 * Returns the directory part of the path. 124 * 125 * This method is similar to PHP's dirname(), but handles various cases 126 * where dirname() returns a weird result: 127 * 128 * - dirname() does not accept backslashes on UNIX 129 * - dirname("C:/symfony") returns "C:", not "C:/" 130 * - dirname("C:/") returns ".", not "C:/" 131 * - dirname("C:") returns ".", not "C:/" 132 * - dirname("symfony") returns ".", not "" 133 * - dirname() does not canonicalize the result 134 * 135 * This method fixes these shortcomings and behaves like dirname() 136 * otherwise. 137 * 138 * The result is a canonical path. 139 * 140 * @return string The canonical directory part. Returns the root directory 141 * if the root directory is passed. Returns an empty string 142 * if a relative path is passed that contains no slashes. 143 * Returns an empty string if an empty string is passed. 144 */ 145 public static function getDirectory(string $path): string 146 { 147 if ('' === $path) { 148 return ''; 149 } 150 151 $path = self::canonicalize($path); 152 153 // Maintain scheme 154 if (false !== ($schemeSeparatorPosition = mb_strpos($path, '://'))) { 155 $scheme = mb_substr($path, 0, $schemeSeparatorPosition + 3); 156 $path = mb_substr($path, $schemeSeparatorPosition + 3); 157 } else { 158 $scheme = ''; 159 } 160 161 if (false === ($dirSeparatorPosition = strrpos($path, '/'))) { 162 return ''; 163 } 164 165 // Directory equals root directory "/" 166 if (0 === $dirSeparatorPosition) { 167 return $scheme.'/'; 168 } 169 170 // Directory equals Windows root "C:/" 171 if (2 === $dirSeparatorPosition && ctype_alpha($path[0]) && ':' === $path[1]) { 172 return $scheme.mb_substr($path, 0, 3); 173 } 174 175 return $scheme.mb_substr($path, 0, $dirSeparatorPosition); 176 } 177 178 /** 179 * Returns canonical path of the user's home directory. 180 * 181 * Supported operating systems: 182 * 183 * - UNIX 184 * - Windows8 and upper 185 * 186 * If your operation system or environment isn't supported, an exception is thrown. 187 * 188 * The result is a canonical path. 189 * 190 * @throws RuntimeException If your operation system or environment isn't supported 191 */ 192 public static function getHomeDirectory(): string 193 { 194 // For UNIX support 195 if (getenv('HOME')) { 196 return self::canonicalize(getenv('HOME')); 197 } 198 199 // For >= Windows8 support 200 if (getenv('HOMEDRIVE') && getenv('HOMEPATH')) { 201 return self::canonicalize(getenv('HOMEDRIVE').getenv('HOMEPATH')); 202 } 203 204 throw new RuntimeException("Cannot find the home directory path: Your environment or operation system isn't supported."); 205 } 206 207 /** 208 * Returns the root directory of a path. 209 * 210 * The result is a canonical path. 211 * 212 * @return string The canonical root directory. Returns an empty string if 213 * the given path is relative or empty. 214 */ 215 public static function getRoot(string $path): string 216 { 217 if ('' === $path) { 218 return ''; 219 } 220 221 // Maintain scheme 222 if (false !== ($schemeSeparatorPosition = strpos($path, '://'))) { 223 $scheme = substr($path, 0, $schemeSeparatorPosition + 3); 224 $path = substr($path, $schemeSeparatorPosition + 3); 225 } else { 226 $scheme = ''; 227 } 228 229 $firstCharacter = $path[0]; 230 231 // UNIX root "/" or "\" (Windows style) 232 if ('/' === $firstCharacter || '\\' === $firstCharacter) { 233 return $scheme.'/'; 234 } 235 236 $length = mb_strlen($path); 237 238 // Windows root 239 if ($length > 1 && ':' === $path[1] && ctype_alpha($firstCharacter)) { 240 // Special case: "C:" 241 if (2 === $length) { 242 return $scheme.$path.'/'; 243 } 244 245 // Normal case: "C:/ or "C:\" 246 if ('/' === $path[2] || '\\' === $path[2]) { 247 return $scheme.$firstCharacter.$path[1].'/'; 248 } 249 } 250 251 return ''; 252 } 253 254 /** 255 * Returns the file name without the extension from a file path. 256 * 257 * @param string|null $extension if specified, only that extension is cut 258 * off (may contain leading dot) 259 */ 260 public static function getFilenameWithoutExtension(string $path, string $extension = null) 261 { 262 if ('' === $path) { 263 return ''; 264 } 265 266 if (null !== $extension) { 267 // remove extension and trailing dot 268 return rtrim(basename($path, $extension), '.'); 269 } 270 271 return pathinfo($path, \PATHINFO_FILENAME); 272 } 273 274 /** 275 * Returns the extension from a file path (without leading dot). 276 * 277 * @param bool $forceLowerCase forces the extension to be lower-case 278 */ 279 public static function getExtension(string $path, bool $forceLowerCase = false): string 280 { 281 if ('' === $path) { 282 return ''; 283 } 284 285 $extension = pathinfo($path, \PATHINFO_EXTENSION); 286 287 if ($forceLowerCase) { 288 $extension = self::toLower($extension); 289 } 290 291 return $extension; 292 } 293 294 /** 295 * Returns whether the path has an (or the specified) extension. 296 * 297 * @param string $path the path string 298 * @param string|string[]|null $extensions if null or not provided, checks if 299 * an extension exists, otherwise 300 * checks for the specified extension 301 * or array of extensions (with or 302 * without leading dot) 303 * @param bool $ignoreCase whether to ignore case-sensitivity 304 */ 305 public static function hasExtension(string $path, $extensions = null, bool $ignoreCase = false): bool 306 { 307 if ('' === $path) { 308 return false; 309 } 310 311 $actualExtension = self::getExtension($path, $ignoreCase); 312 313 // Only check if path has any extension 314 if ([] === $extensions || null === $extensions) { 315 return '' !== $actualExtension; 316 } 317 318 if (\is_string($extensions)) { 319 $extensions = [$extensions]; 320 } 321 322 foreach ($extensions as $key => $extension) { 323 if ($ignoreCase) { 324 $extension = self::toLower($extension); 325 } 326 327 // remove leading '.' in extensions array 328 $extensions[$key] = ltrim($extension, '.'); 329 } 330 331 return \in_array($actualExtension, $extensions, true); 332 } 333 334 /** 335 * Changes the extension of a path string. 336 * 337 * @param string $path The path string with filename.ext to change. 338 * @param string $extension new extension (with or without leading dot) 339 * 340 * @return string the path string with new file extension 341 */ 342 public static function changeExtension(string $path, string $extension): string 343 { 344 if ('' === $path) { 345 return ''; 346 } 347 348 $actualExtension = self::getExtension($path); 349 $extension = ltrim($extension, '.'); 350 351 // No extension for paths 352 if ('/' === mb_substr($path, -1)) { 353 return $path; 354 } 355 356 // No actual extension in path 357 if (empty($actualExtension)) { 358 return $path.('.' === mb_substr($path, -1) ? '' : '.').$extension; 359 } 360 361 return mb_substr($path, 0, -mb_strlen($actualExtension)).$extension; 362 } 363 364 public static function isAbsolute(string $path): bool 365 { 366 if ('' === $path) { 367 return false; 368 } 369 370 // Strip scheme 371 if (false !== ($schemeSeparatorPosition = mb_strpos($path, '://'))) { 372 $path = mb_substr($path, $schemeSeparatorPosition + 3); 373 } 374 375 $firstCharacter = $path[0]; 376 377 // UNIX root "/" or "\" (Windows style) 378 if ('/' === $firstCharacter || '\\' === $firstCharacter) { 379 return true; 380 } 381 382 // Windows root 383 if (mb_strlen($path) > 1 && ctype_alpha($firstCharacter) && ':' === $path[1]) { 384 // Special case: "C:" 385 if (2 === mb_strlen($path)) { 386 return true; 387 } 388 389 // Normal case: "C:/ or "C:\" 390 if ('/' === $path[2] || '\\' === $path[2]) { 391 return true; 392 } 393 } 394 395 return false; 396 } 397 398 public static function isRelative(string $path): bool 399 { 400 return !self::isAbsolute($path); 401 } 402 403 /** 404 * Turns a relative path into an absolute path in canonical form. 405 * 406 * Usually, the relative path is appended to the given base path. Dot 407 * segments ("." and "..") are removed/collapsed and all slashes turned 408 * into forward slashes. 409 * 410 * ```php 411 * echo Path::makeAbsolute("../style.css", "/symfony/puli/css"); 412 * // => /symfony/puli/style.css 413 * ``` 414 * 415 * If an absolute path is passed, that path is returned unless its root 416 * directory is different than the one of the base path. In that case, an 417 * exception is thrown. 418 * 419 * ```php 420 * Path::makeAbsolute("/style.css", "/symfony/puli/css"); 421 * // => /style.css 422 * 423 * Path::makeAbsolute("C:/style.css", "C:/symfony/puli/css"); 424 * // => C:/style.css 425 * 426 * Path::makeAbsolute("C:/style.css", "/symfony/puli/css"); 427 * // InvalidArgumentException 428 * ``` 429 * 430 * If the base path is not an absolute path, an exception is thrown. 431 * 432 * The result is a canonical path. 433 * 434 * @param string $basePath an absolute base path 435 * 436 * @throws InvalidArgumentException if the base path is not absolute or if 437 * the given path is an absolute path with 438 * a different root than the base path 439 */ 440 public static function makeAbsolute(string $path, string $basePath): string 441 { 442 if ('' === $basePath) { 443 throw new InvalidArgumentException(sprintf('The base path must be a non-empty string. Got: "%s".', $basePath)); 444 } 445 446 if (!self::isAbsolute($basePath)) { 447 throw new InvalidArgumentException(sprintf('The base path "%s" is not an absolute path.', $basePath)); 448 } 449 450 if (self::isAbsolute($path)) { 451 return self::canonicalize($path); 452 } 453 454 if (false !== ($schemeSeparatorPosition = mb_strpos($basePath, '://'))) { 455 $scheme = mb_substr($basePath, 0, $schemeSeparatorPosition + 3); 456 $basePath = mb_substr($basePath, $schemeSeparatorPosition + 3); 457 } else { 458 $scheme = ''; 459 } 460 461 return $scheme.self::canonicalize(rtrim($basePath, '/\\').'/'.$path); 462 } 463 464 /** 465 * Turns a path into a relative path. 466 * 467 * The relative path is created relative to the given base path: 468 * 469 * ```php 470 * echo Path::makeRelative("/symfony/style.css", "/symfony/puli"); 471 * // => ../style.css 472 * ``` 473 * 474 * If a relative path is passed and the base path is absolute, the relative 475 * path is returned unchanged: 476 * 477 * ```php 478 * Path::makeRelative("style.css", "/symfony/puli/css"); 479 * // => style.css 480 * ``` 481 * 482 * If both paths are relative, the relative path is created with the 483 * assumption that both paths are relative to the same directory: 484 * 485 * ```php 486 * Path::makeRelative("style.css", "symfony/puli/css"); 487 * // => ../../../style.css 488 * ``` 489 * 490 * If both paths are absolute, their root directory must be the same, 491 * otherwise an exception is thrown: 492 * 493 * ```php 494 * Path::makeRelative("C:/symfony/style.css", "/symfony/puli"); 495 * // InvalidArgumentException 496 * ``` 497 * 498 * If the passed path is absolute, but the base path is not, an exception 499 * is thrown as well: 500 * 501 * ```php 502 * Path::makeRelative("/symfony/style.css", "symfony/puli"); 503 * // InvalidArgumentException 504 * ``` 505 * 506 * If the base path is not an absolute path, an exception is thrown. 507 * 508 * The result is a canonical path. 509 * 510 * @throws InvalidArgumentException if the base path is not absolute or if 511 * the given path has a different root 512 * than the base path 513 */ 514 public static function makeRelative(string $path, string $basePath): string 515 { 516 $path = self::canonicalize($path); 517 $basePath = self::canonicalize($basePath); 518 519 [$root, $relativePath] = self::split($path); 520 [$baseRoot, $relativeBasePath] = self::split($basePath); 521 522 // If the base path is given as absolute path and the path is already 523 // relative, consider it to be relative to the given absolute path 524 // already 525 if ('' === $root && '' !== $baseRoot) { 526 // If base path is already in its root 527 if ('' === $relativeBasePath) { 528 $relativePath = ltrim($relativePath, './\\'); 529 } 530 531 return $relativePath; 532 } 533 534 // If the passed path is absolute, but the base path is not, we 535 // cannot generate a relative path 536 if ('' !== $root && '' === $baseRoot) { 537 throw new InvalidArgumentException(sprintf('The absolute path "%s" cannot be made relative to the relative path "%s". You should provide an absolute base path instead.', $path, $basePath)); 538 } 539 540 // Fail if the roots of the two paths are different 541 if ($baseRoot && $root !== $baseRoot) { 542 throw new InvalidArgumentException(sprintf('The path "%s" cannot be made relative to "%s", because they have different roots ("%s" and "%s").', $path, $basePath, $root, $baseRoot)); 543 } 544 545 if ('' === $relativeBasePath) { 546 return $relativePath; 547 } 548 549 // Build a "../../" prefix with as many "../" parts as necessary 550 $parts = explode('/', $relativePath); 551 $baseParts = explode('/', $relativeBasePath); 552 $dotDotPrefix = ''; 553 554 // Once we found a non-matching part in the prefix, we need to add 555 // "../" parts for all remaining parts 556 $match = true; 557 558 foreach ($baseParts as $index => $basePart) { 559 if ($match && isset($parts[$index]) && $basePart === $parts[$index]) { 560 unset($parts[$index]); 561 562 continue; 563 } 564 565 $match = false; 566 $dotDotPrefix .= '../'; 567 } 568 569 return rtrim($dotDotPrefix.implode('/', $parts), '/'); 570 } 571 572 /** 573 * Returns whether the given path is on the local filesystem. 574 */ 575 public static function isLocal(string $path): bool 576 { 577 return '' !== $path && false === mb_strpos($path, '://'); 578 } 579 580 /** 581 * Returns the longest common base path in canonical form of a set of paths or 582 * `null` if the paths are on different Windows partitions. 583 * 584 * Dot segments ("." and "..") are removed/collapsed and all slashes turned 585 * into forward slashes. 586 * 587 * ```php 588 * $basePath = Path::getLongestCommonBasePath([ 589 * '/symfony/css/style.css', 590 * '/symfony/css/..' 591 * ]); 592 * // => /symfony 593 * ``` 594 * 595 * The root is returned if no common base path can be found: 596 * 597 * ```php 598 * $basePath = Path::getLongestCommonBasePath([ 599 * '/symfony/css/style.css', 600 * '/puli/css/..' 601 * ]); 602 * // => / 603 * ``` 604 * 605 * If the paths are located on different Windows partitions, `null` is 606 * returned. 607 * 608 * ```php 609 * $basePath = Path::getLongestCommonBasePath([ 610 * 'C:/symfony/css/style.css', 611 * 'D:/symfony/css/..' 612 * ]); 613 * // => null 614 * ``` 615 */ 616 public static function getLongestCommonBasePath(string ...$paths): ?string 617 { 618 [$bpRoot, $basePath] = self::split(self::canonicalize(reset($paths))); 619 620 for (next($paths); null !== key($paths) && '' !== $basePath; next($paths)) { 621 [$root, $path] = self::split(self::canonicalize(current($paths))); 622 623 // If we deal with different roots (e.g. C:/ vs. D:/), it's time 624 // to quit 625 if ($root !== $bpRoot) { 626 return null; 627 } 628 629 // Make the base path shorter until it fits into path 630 while (true) { 631 if ('.' === $basePath) { 632 // No more base paths 633 $basePath = ''; 634 635 // next path 636 continue 2; 637 } 638 639 // Prevent false positives for common prefixes 640 // see isBasePath() 641 if (0 === mb_strpos($path.'/', $basePath.'/')) { 642 // next path 643 continue 2; 644 } 645 646 $basePath = \dirname($basePath); 647 } 648 } 649 650 return $bpRoot.$basePath; 651 } 652 653 /** 654 * Joins two or more path strings into a canonical path. 655 */ 656 public static function join(string ...$paths): string 657 { 658 $finalPath = null; 659 $wasScheme = false; 660 661 foreach ($paths as $path) { 662 if ('' === $path) { 663 continue; 664 } 665 666 if (null === $finalPath) { 667 // For first part we keep slashes, like '/top', 'C:\' or 'phar://' 668 $finalPath = $path; 669 $wasScheme = (false !== mb_strpos($path, '://')); 670 continue; 671 } 672 673 // Only add slash if previous part didn't end with '/' or '\' 674 if (!\in_array(mb_substr($finalPath, -1), ['/', '\\'])) { 675 $finalPath .= '/'; 676 } 677 678 // If first part included a scheme like 'phar://' we allow \current part to start with '/', otherwise trim 679 $finalPath .= $wasScheme ? $path : ltrim($path, '/'); 680 $wasScheme = false; 681 } 682 683 if (null === $finalPath) { 684 return ''; 685 } 686 687 return self::canonicalize($finalPath); 688 } 689 690 /** 691 * Returns whether a path is a base path of another path. 692 * 693 * Dot segments ("." and "..") are removed/collapsed and all slashes turned 694 * into forward slashes. 695 * 696 * ```php 697 * Path::isBasePath('/symfony', '/symfony/css'); 698 * // => true 699 * 700 * Path::isBasePath('/symfony', '/symfony'); 701 * // => true 702 * 703 * Path::isBasePath('/symfony', '/symfony/..'); 704 * // => false 705 * 706 * Path::isBasePath('/symfony', '/puli'); 707 * // => false 708 * ``` 709 */ 710 public static function isBasePath(string $basePath, string $ofPath): bool 711 { 712 $basePath = self::canonicalize($basePath); 713 $ofPath = self::canonicalize($ofPath); 714 715 // Append slashes to prevent false positives when two paths have 716 // a common prefix, for example /base/foo and /base/foobar. 717 // Don't append a slash for the root "/", because then that root 718 // won't be discovered as common prefix ("//" is not a prefix of 719 // "/foobar/"). 720 return 0 === mb_strpos($ofPath.'/', rtrim($basePath, '/').'/'); 721 } 722 723 /** 724 * @return non-empty-string[] 725 */ 726 private static function findCanonicalParts(string $root, string $pathWithoutRoot): array 727 { 728 $parts = explode('/', $pathWithoutRoot); 729 730 $canonicalParts = []; 731 732 // Collapse "." and "..", if possible 733 foreach ($parts as $part) { 734 if ('.' === $part || '' === $part) { 735 continue; 736 } 737 738 // Collapse ".." with the previous part, if one exists 739 // Don't collapse ".." if the previous part is also ".." 740 if ('..' === $part && \count($canonicalParts) > 0 && '..' !== $canonicalParts[\count($canonicalParts) - 1]) { 741 array_pop($canonicalParts); 742 743 continue; 744 } 745 746 // Only add ".." prefixes for relative paths 747 if ('..' !== $part || '' === $root) { 748 $canonicalParts[] = $part; 749 } 750 } 751 752 return $canonicalParts; 753 } 754 755 /** 756 * Splits a canonical path into its root directory and the remainder. 757 * 758 * If the path has no root directory, an empty root directory will be 759 * returned. 760 * 761 * If the root directory is a Windows style partition, the resulting root 762 * will always contain a trailing slash. 763 * 764 * list ($root, $path) = Path::split("C:/symfony") 765 * // => ["C:/", "symfony"] 766 * 767 * list ($root, $path) = Path::split("C:") 768 * // => ["C:/", ""] 769 * 770 * @return array{string, string} an array with the root directory and the remaining relative path 771 */ 772 private static function split(string $path): array 773 { 774 if ('' === $path) { 775 return ['', '']; 776 } 777 778 // Remember scheme as part of the root, if any 779 if (false !== ($schemeSeparatorPosition = mb_strpos($path, '://'))) { 780 $root = mb_substr($path, 0, $schemeSeparatorPosition + 3); 781 $path = mb_substr($path, $schemeSeparatorPosition + 3); 782 } else { 783 $root = ''; 784 } 785 786 $length = mb_strlen($path); 787 788 // Remove and remember root directory 789 if (0 === mb_strpos($path, '/')) { 790 $root .= '/'; 791 $path = $length > 1 ? mb_substr($path, 1) : ''; 792 } elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) { 793 if (2 === $length) { 794 // Windows special case: "C:" 795 $root .= $path.'/'; 796 $path = ''; 797 } elseif ('/' === $path[2]) { 798 // Windows normal case: "C:/".. 799 $root .= mb_substr($path, 0, 3); 800 $path = $length > 3 ? mb_substr($path, 3) : ''; 801 } 802 } 803 804 return [$root, $path]; 805 } 806 807 private static function toLower(string $string): string 808 { 809 if (false !== $encoding = mb_detect_encoding($string)) { 810 return mb_strtolower($string, $encoding); 811 } 812 813 return strtolower($string, $encoding); 814 } 815 816 private function __construct() 817 { 818 } 819} 820