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