├── CHANGELOG.md ├── Exception ├── ExceptionInterface.php ├── FileNotFoundException.php ├── IOException.php ├── IOExceptionInterface.php ├── InvalidArgumentException.php └── RuntimeException.php ├── Filesystem.php ├── LICENSE ├── Path.php ├── README.md └── composer.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.1 5 | --- 6 | 7 | * Add the `Filesystem::readFile()` method 8 | 9 | 7.0 10 | --- 11 | 12 | * Add argument `$lock` to `Filesystem::appendToFile()` 13 | 14 | 5.4 15 | --- 16 | 17 | * Add `Path` class 18 | * Add `$lock` argument to `Filesystem::appendToFile()` 19 | 20 | 5.0.0 21 | ----- 22 | 23 | * `Filesystem::dumpFile()` and `appendToFile()` don't accept arrays anymore 24 | 25 | 4.4.0 26 | ----- 27 | 28 | * support for passing a `null` value to `Filesystem::isAbsolutePath()` is deprecated and will be removed in 5.0 29 | * `tempnam()` now accepts a third argument `$suffix`. 30 | 31 | 4.3.0 32 | ----- 33 | 34 | * support for passing arrays to `Filesystem::dumpFile()` is deprecated and will be removed in 5.0 35 | * support for passing arrays to `Filesystem::appendToFile()` is deprecated and will be removed in 5.0 36 | 37 | 4.0.0 38 | ----- 39 | 40 | * removed `LockHandler` 41 | * Support for passing relative paths to `Filesystem::makePathRelative()` has been removed. 42 | 43 | 3.4.0 44 | ----- 45 | 46 | * support for passing relative paths to `Filesystem::makePathRelative()` is deprecated and will be removed in 4.0 47 | 48 | 3.3.0 49 | ----- 50 | 51 | * added `appendToFile()` to append contents to existing files 52 | 53 | 3.2.0 54 | ----- 55 | 56 | * added `readlink()` as a platform independent method to read links 57 | 58 | 3.0.0 59 | ----- 60 | 61 | * removed `$mode` argument from `Filesystem::dumpFile()` 62 | 63 | 2.8.0 64 | ----- 65 | 66 | * added tempnam() a stream aware version of PHP's native tempnam() 67 | 68 | 2.6.0 69 | ----- 70 | 71 | * added LockHandler 72 | 73 | 2.3.12 74 | ------ 75 | 76 | * deprecated dumpFile() file mode argument. 77 | 78 | 2.3.0 79 | ----- 80 | 81 | * added the dumpFile() method to atomically write files 82 | 83 | 2.2.0 84 | ----- 85 | 86 | * added a delete option for the mirror() method 87 | 88 | 2.1.0 89 | ----- 90 | 91 | * 24eb396 : BC Break : mkdir() function now throws exception in case of failure instead of returning Boolean value 92 | * created the component 93 | -------------------------------------------------------------------------------- /Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem\Exception; 13 | 14 | /** 15 | * Exception interface for all exceptions thrown by the component. 16 | * 17 | * @author Romain Neutron 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /Exception/FileNotFoundException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem\Exception; 13 | 14 | /** 15 | * Exception class thrown when a file couldn't be found. 16 | * 17 | * @author Fabien Potencier 18 | * @author Christian Gärtner 19 | */ 20 | class FileNotFoundException extends IOException 21 | { 22 | public function __construct(?string $message = null, int $code = 0, ?\Throwable $previous = null, ?string $path = null) 23 | { 24 | if (null === $message) { 25 | if (null === $path) { 26 | $message = 'File could not be found.'; 27 | } else { 28 | $message = \sprintf('File "%s" could not be found.', $path); 29 | } 30 | } 31 | 32 | parent::__construct($message, $code, $previous, $path); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Exception/IOException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem\Exception; 13 | 14 | /** 15 | * Exception class thrown when a filesystem operation failure happens. 16 | * 17 | * @author Romain Neutron 18 | * @author Christian Gärtner 19 | * @author Fabien Potencier 20 | */ 21 | class IOException extends \RuntimeException implements IOExceptionInterface 22 | { 23 | public function __construct( 24 | string $message, 25 | int $code = 0, 26 | ?\Throwable $previous = null, 27 | private ?string $path = null, 28 | ) { 29 | parent::__construct($message, $code, $previous); 30 | } 31 | 32 | public function getPath(): ?string 33 | { 34 | return $this->path; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Exception/IOExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem\Exception; 13 | 14 | /** 15 | * IOException interface for file and input/output stream related exceptions thrown by the component. 16 | * 17 | * @author Christian Gärtner 18 | */ 19 | interface IOExceptionInterface extends ExceptionInterface 20 | { 21 | /** 22 | * Returns the associated path for the exception. 23 | */ 24 | public function getPath(): ?string; 25 | } 26 | -------------------------------------------------------------------------------- /Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem\Exception; 13 | 14 | /** 15 | * @author Christian Flothmann 16 | */ 17 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem\Exception; 13 | 14 | /** 15 | * @author Théo Fidry 16 | */ 17 | class RuntimeException extends \RuntimeException implements ExceptionInterface 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /Filesystem.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem; 13 | 14 | use Symfony\Component\Filesystem\Exception\FileNotFoundException; 15 | use Symfony\Component\Filesystem\Exception\InvalidArgumentException; 16 | use Symfony\Component\Filesystem\Exception\IOException; 17 | 18 | /** 19 | * Provides basic utility to manipulate the file system. 20 | * 21 | * @author Fabien Potencier 22 | */ 23 | class Filesystem 24 | { 25 | private static ?string $lastError = null; 26 | 27 | /** 28 | * Copies a file. 29 | * 30 | * If the target file is older than the origin file, it's always overwritten. 31 | * If the target file is newer, it is overwritten only when the 32 | * $overwriteNewerFiles option is set to true. 33 | * 34 | * @throws FileNotFoundException When originFile doesn't exist 35 | * @throws IOException When copy fails 36 | */ 37 | public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false): void 38 | { 39 | $originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://'); 40 | if ($originIsLocal && !is_file($originFile)) { 41 | throw new FileNotFoundException(\sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile); 42 | } 43 | 44 | $this->mkdir(\dirname($targetFile)); 45 | 46 | $doCopy = true; 47 | if (!$overwriteNewerFiles && !parse_url($originFile, \PHP_URL_HOST) && is_file($targetFile)) { 48 | $doCopy = filemtime($originFile) > filemtime($targetFile); 49 | } 50 | 51 | if ($doCopy) { 52 | // https://bugs.php.net/64634 53 | if (!$source = self::box('fopen', $originFile, 'r')) { 54 | throw new IOException(\sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); 55 | } 56 | 57 | // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default 58 | if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) { 59 | throw new IOException(\sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); 60 | } 61 | 62 | $bytesCopied = stream_copy_to_stream($source, $target); 63 | fclose($source); 64 | fclose($target); 65 | unset($source, $target); 66 | 67 | if (!is_file($targetFile)) { 68 | throw new IOException(\sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile); 69 | } 70 | 71 | if ($originIsLocal) { 72 | // Like `cp`, preserve executable permission bits 73 | self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111)); 74 | 75 | // Like `cp`, preserve the file modification time 76 | self::box('touch', $targetFile, filemtime($originFile)); 77 | 78 | if ($bytesCopied !== $bytesOrigin = filesize($originFile)) { 79 | throw new IOException(\sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile); 80 | } 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * Creates a directory recursively. 87 | * 88 | * @throws IOException On any directory creation failure 89 | */ 90 | public function mkdir(string|iterable $dirs, int $mode = 0777): void 91 | { 92 | foreach ($this->toIterable($dirs) as $dir) { 93 | if (is_dir($dir)) { 94 | continue; 95 | } 96 | 97 | if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) { 98 | throw new IOException(\sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); 99 | } 100 | } 101 | } 102 | 103 | /** 104 | * Checks the existence of files or directories. 105 | */ 106 | public function exists(string|iterable $files): bool 107 | { 108 | $maxPathLength = \PHP_MAXPATHLEN - 2; 109 | 110 | foreach ($this->toIterable($files) as $file) { 111 | if (\strlen($file) > $maxPathLength) { 112 | throw new IOException(\sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file); 113 | } 114 | 115 | if (!file_exists($file)) { 116 | return false; 117 | } 118 | } 119 | 120 | return true; 121 | } 122 | 123 | /** 124 | * Sets access and modification time of file. 125 | * 126 | * @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used 127 | * @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used 128 | * 129 | * @throws IOException When touch fails 130 | */ 131 | public function touch(string|iterable $files, ?int $time = null, ?int $atime = null): void 132 | { 133 | foreach ($this->toIterable($files) as $file) { 134 | if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) { 135 | throw new IOException(\sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * Removes files or directories. 142 | * 143 | * @throws IOException When removal fails 144 | */ 145 | public function remove(string|iterable $files): void 146 | { 147 | if ($files instanceof \Traversable) { 148 | $files = iterator_to_array($files, false); 149 | } elseif (!\is_array($files)) { 150 | $files = [$files]; 151 | } 152 | 153 | self::doRemove($files, false); 154 | } 155 | 156 | private static function doRemove(array $files, bool $isRecursive): void 157 | { 158 | $files = array_reverse($files); 159 | foreach ($files as $file) { 160 | if (is_link($file)) { 161 | // See https://bugs.php.net/52176 162 | if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) { 163 | throw new IOException(\sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); 164 | } 165 | } elseif (is_dir($file)) { 166 | if (!$isRecursive) { 167 | $tmpName = \dirname(realpath($file)).'/.!'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-!')); 168 | 169 | if (file_exists($tmpName)) { 170 | try { 171 | self::doRemove([$tmpName], true); 172 | } catch (IOException) { 173 | } 174 | } 175 | 176 | if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) { 177 | $origFile = $file; 178 | $file = $tmpName; 179 | } else { 180 | $origFile = null; 181 | } 182 | } 183 | 184 | $filesystemIterator = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); 185 | self::doRemove(iterator_to_array($filesystemIterator, true), true); 186 | 187 | if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { 188 | $lastError = self::$lastError; 189 | 190 | if (null !== $origFile && self::box('rename', $file, $origFile)) { 191 | $file = $origFile; 192 | } 193 | 194 | throw new IOException(\sprintf('Failed to remove directory "%s": ', $file).$lastError); 195 | } 196 | } elseif (!self::box('unlink', $file) && ((self::$lastError && str_contains(self::$lastError, 'Permission denied')) || file_exists($file))) { 197 | throw new IOException(\sprintf('Failed to remove file "%s": ', $file).self::$lastError); 198 | } 199 | } 200 | } 201 | 202 | /** 203 | * Change mode for an array of files or directories. 204 | * 205 | * @param int $mode The new mode (octal) 206 | * @param int $umask The mode mask (octal) 207 | * @param bool $recursive Whether change the mod recursively or not 208 | * 209 | * @throws IOException When the change fails 210 | */ 211 | public function chmod(string|iterable $files, int $mode, int $umask = 0000, bool $recursive = false): void 212 | { 213 | foreach ($this->toIterable($files) as $file) { 214 | if (!self::box('chmod', $file, $mode & ~$umask)) { 215 | throw new IOException(\sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); 216 | } 217 | if ($recursive && is_dir($file) && !is_link($file)) { 218 | $this->chmod(new \FilesystemIterator($file), $mode, $umask, true); 219 | } 220 | } 221 | } 222 | 223 | /** 224 | * Change the owner of an array of files or directories. 225 | * 226 | * This method always throws on Windows, as the underlying PHP function is not supported. 227 | * 228 | * @see https://www.php.net/chown 229 | * 230 | * @param string|int $user A user name or number 231 | * @param bool $recursive Whether change the owner recursively or not 232 | * 233 | * @throws IOException When the change fails 234 | */ 235 | public function chown(string|iterable $files, string|int $user, bool $recursive = false): void 236 | { 237 | foreach ($this->toIterable($files) as $file) { 238 | if ($recursive && is_dir($file) && !is_link($file)) { 239 | $this->chown(new \FilesystemIterator($file), $user, true); 240 | } 241 | if (is_link($file) && \function_exists('lchown')) { 242 | if (!self::box('lchown', $file, $user)) { 243 | throw new IOException(\sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); 244 | } 245 | } else { 246 | if (!self::box('chown', $file, $user)) { 247 | throw new IOException(\sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); 248 | } 249 | } 250 | } 251 | } 252 | 253 | /** 254 | * Change the group of an array of files or directories. 255 | * 256 | * This method always throws on Windows, as the underlying PHP function is not supported. 257 | * 258 | * @see https://www.php.net/chgrp 259 | * 260 | * @param string|int $group A group name or number 261 | * @param bool $recursive Whether change the group recursively or not 262 | * 263 | * @throws IOException When the change fails 264 | */ 265 | public function chgrp(string|iterable $files, string|int $group, bool $recursive = false): void 266 | { 267 | foreach ($this->toIterable($files) as $file) { 268 | if ($recursive && is_dir($file) && !is_link($file)) { 269 | $this->chgrp(new \FilesystemIterator($file), $group, true); 270 | } 271 | if (is_link($file) && \function_exists('lchgrp')) { 272 | if (!self::box('lchgrp', $file, $group)) { 273 | throw new IOException(\sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); 274 | } 275 | } else { 276 | if (!self::box('chgrp', $file, $group)) { 277 | throw new IOException(\sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); 278 | } 279 | } 280 | } 281 | } 282 | 283 | /** 284 | * Renames a file or a directory. 285 | * 286 | * @throws IOException When target file or directory already exists 287 | * @throws IOException When origin cannot be renamed 288 | */ 289 | public function rename(string $origin, string $target, bool $overwrite = false): void 290 | { 291 | // we check that target does not exist 292 | if (!$overwrite && $this->isReadable($target)) { 293 | throw new IOException(\sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); 294 | } 295 | 296 | if (!self::box('rename', $origin, $target)) { 297 | if (is_dir($origin)) { 298 | // See https://bugs.php.net/54097 & https://php.net/rename#113943 299 | $this->mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]); 300 | $this->remove($origin); 301 | 302 | return; 303 | } 304 | throw new IOException(\sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); 305 | } 306 | } 307 | 308 | /** 309 | * Tells whether a file exists and is readable. 310 | * 311 | * @throws IOException When windows path is longer than 258 characters 312 | */ 313 | private function isReadable(string $filename): bool 314 | { 315 | $maxPathLength = \PHP_MAXPATHLEN - 2; 316 | 317 | if (\strlen($filename) > $maxPathLength) { 318 | throw new IOException(\sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename); 319 | } 320 | 321 | return is_readable($filename); 322 | } 323 | 324 | /** 325 | * Creates a symbolic link or copy a directory. 326 | * 327 | * @throws IOException When symlink fails 328 | */ 329 | public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false): void 330 | { 331 | self::assertFunctionExists('symlink'); 332 | 333 | if ('\\' === \DIRECTORY_SEPARATOR) { 334 | $originDir = strtr($originDir, '/', '\\'); 335 | $targetDir = strtr($targetDir, '/', '\\'); 336 | 337 | if ($copyOnWindows) { 338 | $this->mirror($originDir, $targetDir); 339 | 340 | return; 341 | } 342 | } 343 | 344 | $this->mkdir(\dirname($targetDir)); 345 | 346 | if (is_link($targetDir)) { 347 | if (readlink($targetDir) === $originDir) { 348 | return; 349 | } 350 | $this->remove($targetDir); 351 | } 352 | 353 | if (!self::box('symlink', $originDir, $targetDir)) { 354 | $this->linkException($originDir, $targetDir, 'symbolic'); 355 | } 356 | } 357 | 358 | /** 359 | * Creates a hard link, or several hard links to a file. 360 | * 361 | * @param string|string[] $targetFiles The target file(s) 362 | * 363 | * @throws FileNotFoundException When original file is missing or not a file 364 | * @throws IOException When link fails, including if link already exists 365 | */ 366 | public function hardlink(string $originFile, string|iterable $targetFiles): void 367 | { 368 | self::assertFunctionExists('link'); 369 | 370 | if (!$this->exists($originFile)) { 371 | throw new FileNotFoundException(null, 0, null, $originFile); 372 | } 373 | 374 | if (!is_file($originFile)) { 375 | throw new FileNotFoundException(\sprintf('Origin file "%s" is not a file.', $originFile)); 376 | } 377 | 378 | foreach ($this->toIterable($targetFiles) as $targetFile) { 379 | if (is_file($targetFile)) { 380 | if (fileinode($originFile) === fileinode($targetFile)) { 381 | continue; 382 | } 383 | $this->remove($targetFile); 384 | } 385 | 386 | if (!self::box('link', $originFile, $targetFile)) { 387 | $this->linkException($originFile, $targetFile, 'hard'); 388 | } 389 | } 390 | } 391 | 392 | /** 393 | * @param string $linkType Name of the link type, typically 'symbolic' or 'hard' 394 | */ 395 | private function linkException(string $origin, string $target, string $linkType): never 396 | { 397 | if (self::$lastError) { 398 | if ('\\' === \DIRECTORY_SEPARATOR && str_contains(self::$lastError, 'error code(1314)')) { 399 | throw new IOException(\sprintf('Unable to create "%s" link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target); 400 | } 401 | } 402 | throw new IOException(\sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); 403 | } 404 | 405 | /** 406 | * Resolves links in paths. 407 | * 408 | * With $canonicalize = false (default) 409 | * - if $path does not exist or is not a link, returns null 410 | * - if $path is a link, returns the next direct target of the link without considering the existence of the target 411 | * 412 | * With $canonicalize = true 413 | * - if $path does not exist, returns null 414 | * - if $path exists, returns its absolute fully resolved final version 415 | */ 416 | public function readlink(string $path, bool $canonicalize = false): ?string 417 | { 418 | if (!$canonicalize && !is_link($path)) { 419 | return null; 420 | } 421 | 422 | if ($canonicalize) { 423 | if (!$this->exists($path)) { 424 | return null; 425 | } 426 | 427 | return realpath($path); 428 | } 429 | 430 | return readlink($path); 431 | } 432 | 433 | /** 434 | * Given an existing path, convert it to a path relative to a given starting path. 435 | */ 436 | public function makePathRelative(string $endPath, string $startPath): string 437 | { 438 | if (!$this->isAbsolutePath($startPath)) { 439 | throw new InvalidArgumentException(\sprintf('The start path "%s" is not absolute.', $startPath)); 440 | } 441 | 442 | if (!$this->isAbsolutePath($endPath)) { 443 | throw new InvalidArgumentException(\sprintf('The end path "%s" is not absolute.', $endPath)); 444 | } 445 | 446 | // Normalize separators on Windows 447 | if ('\\' === \DIRECTORY_SEPARATOR) { 448 | $endPath = str_replace('\\', '/', $endPath); 449 | $startPath = str_replace('\\', '/', $startPath); 450 | } 451 | 452 | $splitDriveLetter = fn ($path) => (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0])) 453 | ? [substr($path, 2), strtoupper($path[0])] 454 | : [$path, null]; 455 | 456 | $splitPath = function ($path) { 457 | $result = []; 458 | 459 | foreach (explode('/', trim($path, '/')) as $segment) { 460 | if ('..' === $segment) { 461 | array_pop($result); 462 | } elseif ('.' !== $segment && '' !== $segment) { 463 | $result[] = $segment; 464 | } 465 | } 466 | 467 | return $result; 468 | }; 469 | 470 | [$endPath, $endDriveLetter] = $splitDriveLetter($endPath); 471 | [$startPath, $startDriveLetter] = $splitDriveLetter($startPath); 472 | 473 | $startPathArr = $splitPath($startPath); 474 | $endPathArr = $splitPath($endPath); 475 | 476 | if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) { 477 | // End path is on another drive, so no relative path exists 478 | return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : ''); 479 | } 480 | 481 | // Find for which directory the common path stops 482 | $index = 0; 483 | while (isset($startPathArr[$index]) && isset($endPathArr[$index]) && $startPathArr[$index] === $endPathArr[$index]) { 484 | ++$index; 485 | } 486 | 487 | // Determine how deep the start path is relative to the common path (ie, "web/bundles" = 2 levels) 488 | if (1 === \count($startPathArr) && '' === $startPathArr[0]) { 489 | $depth = 0; 490 | } else { 491 | $depth = \count($startPathArr) - $index; 492 | } 493 | 494 | // Repeated "../" for each level need to reach the common path 495 | $traverser = str_repeat('../', $depth); 496 | 497 | $endPathRemainder = implode('/', \array_slice($endPathArr, $index)); 498 | 499 | // Construct $endPath from traversing to the common path, then to the remaining $endPath 500 | $relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : ''); 501 | 502 | return '' === $relativePath ? './' : $relativePath; 503 | } 504 | 505 | /** 506 | * Mirrors a directory to another. 507 | * 508 | * Copies files and directories from the origin directory into the target directory. By default: 509 | * 510 | * - existing files in the target directory will be overwritten, except if they are newer (see the `override` option) 511 | * - files in the target directory that do not exist in the source directory will not be deleted (see the `delete` option) 512 | * 513 | * @param \Traversable|null $iterator Iterator that filters which files and directories to copy, if null a recursive iterator is created 514 | * @param array $options An array of boolean options 515 | * Valid options are: 516 | * - $options['override'] If true, target files newer than origin files are overwritten (see copy(), defaults to false) 517 | * - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false) 518 | * - $options['delete'] Whether to delete files that are not in the source directory (defaults to false) 519 | * 520 | * @throws IOException When file type is unknown 521 | */ 522 | public function mirror(string $originDir, string $targetDir, ?\Traversable $iterator = null, array $options = []): void 523 | { 524 | $targetDir = rtrim($targetDir, '/\\'); 525 | $originDir = rtrim($originDir, '/\\'); 526 | $originDirLen = \strlen($originDir); 527 | 528 | if (!$this->exists($originDir)) { 529 | throw new IOException(\sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir); 530 | } 531 | 532 | // Iterate in destination folder to remove obsolete entries 533 | if ($this->exists($targetDir) && isset($options['delete']) && $options['delete']) { 534 | $deleteIterator = $iterator; 535 | if (null === $deleteIterator) { 536 | $flags = \FilesystemIterator::SKIP_DOTS; 537 | $deleteIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($targetDir, $flags), \RecursiveIteratorIterator::CHILD_FIRST); 538 | } 539 | $targetDirLen = \strlen($targetDir); 540 | foreach ($deleteIterator as $file) { 541 | $origin = $originDir.substr($file->getPathname(), $targetDirLen); 542 | if (!$this->exists($origin)) { 543 | $this->remove($file); 544 | } 545 | } 546 | } 547 | 548 | $copyOnWindows = $options['copy_on_windows'] ?? false; 549 | 550 | if (null === $iterator) { 551 | $flags = $copyOnWindows ? \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS : \FilesystemIterator::SKIP_DOTS; 552 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($originDir, $flags), \RecursiveIteratorIterator::SELF_FIRST); 553 | } 554 | 555 | $this->mkdir($targetDir); 556 | $filesCreatedWhileMirroring = []; 557 | 558 | foreach ($iterator as $file) { 559 | if ($file->getPathname() === $targetDir || $file->getRealPath() === $targetDir || isset($filesCreatedWhileMirroring[$file->getRealPath()])) { 560 | continue; 561 | } 562 | 563 | $target = $targetDir.substr($file->getPathname(), $originDirLen); 564 | $filesCreatedWhileMirroring[$target] = true; 565 | 566 | if (!$copyOnWindows && is_link($file)) { 567 | $this->symlink($file->getLinkTarget(), $target); 568 | } elseif (is_dir($file)) { 569 | $this->mkdir($target); 570 | } elseif (is_file($file)) { 571 | $this->copy($file, $target, $options['override'] ?? false); 572 | } else { 573 | throw new IOException(\sprintf('Unable to guess "%s" file type.', $file), 0, null, $file); 574 | } 575 | } 576 | } 577 | 578 | /** 579 | * Returns whether the file path is an absolute path. 580 | */ 581 | public function isAbsolutePath(string $file): bool 582 | { 583 | return '' !== $file && (strspn($file, '/\\', 0, 1) 584 | || (\strlen($file) > 3 && ctype_alpha($file[0]) 585 | && ':' === $file[1] 586 | && strspn($file, '/\\', 2, 1) 587 | ) 588 | || null !== parse_url($file, \PHP_URL_SCHEME) 589 | ); 590 | } 591 | 592 | /** 593 | * Creates a temporary file with support for custom stream wrappers. 594 | * 595 | * @param string $prefix The prefix of the generated temporary filename 596 | * Note: Windows uses only the first three characters of prefix 597 | * @param string $suffix The suffix of the generated temporary filename 598 | * 599 | * @return string The new temporary filename (with path), or throw an exception on failure 600 | */ 601 | public function tempnam(string $dir, string $prefix, string $suffix = ''): string 602 | { 603 | [$scheme, $hierarchy] = $this->getSchemeAndHierarchy($dir); 604 | 605 | // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem 606 | if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) { 607 | // If tempnam failed or no scheme return the filename otherwise prepend the scheme 608 | if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) { 609 | if (null !== $scheme && 'gs' !== $scheme) { 610 | return $scheme.'://'.$tmpFile; 611 | } 612 | 613 | return $tmpFile; 614 | } 615 | 616 | throw new IOException('A temporary file could not be created: '.self::$lastError); 617 | } 618 | 619 | // Loop until we create a valid temp file or have reached 10 attempts 620 | for ($i = 0; $i < 10; ++$i) { 621 | // Create a unique filename 622 | $tmpFile = $dir.'/'.$prefix.bin2hex(random_bytes(4)).$suffix; 623 | 624 | // Use fopen instead of file_exists as some streams do not support stat 625 | // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability 626 | if (!$handle = self::box('fopen', $tmpFile, 'x+')) { 627 | continue; 628 | } 629 | 630 | // Close the file if it was successfully opened 631 | self::box('fclose', $handle); 632 | 633 | return $tmpFile; 634 | } 635 | 636 | throw new IOException('A temporary file could not be created: '.self::$lastError); 637 | } 638 | 639 | /** 640 | * Atomically dumps content into a file. 641 | * 642 | * @param string|resource $content The data to write into the file 643 | * 644 | * @throws IOException if the file cannot be written to 645 | */ 646 | public function dumpFile(string $filename, $content): void 647 | { 648 | if (\is_array($content)) { 649 | throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); 650 | } 651 | 652 | $dir = \dirname($filename); 653 | 654 | if (is_link($filename) && $linkTarget = $this->readlink($filename)) { 655 | $this->dumpFile(Path::makeAbsolute($linkTarget, $dir), $content); 656 | 657 | return; 658 | } 659 | 660 | if (!is_dir($dir)) { 661 | $this->mkdir($dir); 662 | } 663 | 664 | // Will create a temp file with 0600 access rights 665 | // when the filesystem supports chmod. 666 | $tmpFile = $this->tempnam($dir, basename($filename)); 667 | 668 | try { 669 | if (false === self::box('file_put_contents', $tmpFile, $content)) { 670 | throw new IOException(\sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); 671 | } 672 | 673 | self::box('chmod', $tmpFile, self::box('fileperms', $filename) ?: 0666 & ~umask()); 674 | 675 | $this->rename($tmpFile, $filename, true); 676 | } finally { 677 | if (file_exists($tmpFile)) { 678 | if ('\\' === \DIRECTORY_SEPARATOR && !is_writable($tmpFile)) { 679 | self::box('chmod', $tmpFile, self::box('fileperms', $tmpFile) | 0200); 680 | } 681 | 682 | self::box('unlink', $tmpFile); 683 | } 684 | } 685 | } 686 | 687 | /** 688 | * Appends content to an existing file. 689 | * 690 | * @param string|resource $content The content to append 691 | * @param bool $lock Whether the file should be locked when writing to it 692 | * 693 | * @throws IOException If the file is not writable 694 | */ 695 | public function appendToFile(string $filename, $content, bool $lock = false): void 696 | { 697 | if (\is_array($content)) { 698 | throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); 699 | } 700 | 701 | $dir = \dirname($filename); 702 | 703 | if (!is_dir($dir)) { 704 | $this->mkdir($dir); 705 | } 706 | 707 | if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND | ($lock ? \LOCK_EX : 0))) { 708 | throw new IOException(\sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); 709 | } 710 | } 711 | 712 | /** 713 | * Returns the content of a file as a string. 714 | * 715 | * @throws IOException If the file cannot be read 716 | */ 717 | public function readFile(string $filename): string 718 | { 719 | if (is_dir($filename)) { 720 | throw new IOException(\sprintf('Failed to read file "%s": File is a directory.', $filename)); 721 | } 722 | 723 | $content = self::box('file_get_contents', $filename); 724 | if (false === $content) { 725 | throw new IOException(\sprintf('Failed to read file "%s": ', $filename).self::$lastError, 0, null, $filename); 726 | } 727 | 728 | return $content; 729 | } 730 | 731 | private function toIterable(string|iterable $files): iterable 732 | { 733 | return is_iterable($files) ? $files : [$files]; 734 | } 735 | 736 | /** 737 | * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> [file, tmp]). 738 | */ 739 | private function getSchemeAndHierarchy(string $filename): array 740 | { 741 | $components = explode('://', $filename, 2); 742 | 743 | return 2 === \count($components) ? [$components[0], $components[1]] : [null, $components[0]]; 744 | } 745 | 746 | private static function assertFunctionExists(string $func): void 747 | { 748 | if (!\function_exists($func)) { 749 | throw new IOException(\sprintf('Unable to perform filesystem operation because the "%s()" function has been disabled.', $func)); 750 | } 751 | } 752 | 753 | private static function box(string $func, mixed ...$args): mixed 754 | { 755 | self::assertFunctionExists($func); 756 | 757 | self::$lastError = null; 758 | set_error_handler(self::handleError(...)); 759 | try { 760 | return $func(...$args); 761 | } finally { 762 | restore_error_handler(); 763 | } 764 | } 765 | 766 | /** 767 | * @internal 768 | */ 769 | public static function handleError(int $type, string $msg): void 770 | { 771 | self::$lastError = $msg; 772 | } 773 | } 774 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Path.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Filesystem; 13 | 14 | use Symfony\Component\Filesystem\Exception\InvalidArgumentException; 15 | use 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 25 | * @author Thomas Schulz 26 | * @author Théo Fidry 27 | */ 28 | final 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 44 | */ 45 | private static array $buffer = []; 46 | 47 | private static int $bufferSize = 0; 48 | 49 | /** 50 | * Canonicalizes the given path. 51 | * 52 | * During normalization, all slashes are replaced by forward slashes ("/"). 53 | * Furthermore, all "." and ".." segments are removed as far as possible. 54 | * ".." segments at the beginning of relative paths are not removed. 55 | * 56 | * ```php 57 | * echo Path::canonicalize("\symfony\puli\..\css\style.css"); 58 | * // => /symfony/css/style.css 59 | * 60 | * echo Path::canonicalize("../css/./style.css"); 61 | * // => ../css/style.css 62 | * ``` 63 | * 64 | * This method is able to deal with both UNIX and Windows paths. 65 | */ 66 | public static function canonicalize(string $path): string 67 | { 68 | if ('' === $path) { 69 | return ''; 70 | } 71 | 72 | // This method is called by many other methods in this class. Buffer 73 | // the canonicalized paths to make up for the severe performance 74 | // decrease. 75 | if (isset(self::$buffer[$path])) { 76 | return self::$buffer[$path]; 77 | } 78 | 79 | // Replace "~" with user's home directory. 80 | if ('~' === $path[0]) { 81 | $path = self::getHomeDirectory().substr($path, 1); 82 | } 83 | 84 | $path = self::normalize($path); 85 | 86 | [$root, $pathWithoutRoot] = self::split($path); 87 | 88 | $canonicalParts = self::findCanonicalParts($root, $pathWithoutRoot); 89 | 90 | // Add the root directory again 91 | self::$buffer[$path] = $canonicalPath = $root.implode('/', $canonicalParts); 92 | ++self::$bufferSize; 93 | 94 | // Clean up regularly to prevent memory leaks 95 | if (self::$bufferSize > self::CLEANUP_THRESHOLD) { 96 | self::$buffer = \array_slice(self::$buffer, -self::CLEANUP_SIZE, null, true); 97 | self::$bufferSize = self::CLEANUP_SIZE; 98 | } 99 | 100 | return $canonicalPath; 101 | } 102 | 103 | /** 104 | * Normalizes the given path. 105 | * 106 | * During normalization, all slashes are replaced by forward slashes ("/"). 107 | * Contrary to {@link canonicalize()}, this method does not remove invalid 108 | * or dot path segments. Consequently, it is much more efficient and should 109 | * be used whenever the given path is known to be a valid, absolute system 110 | * path. 111 | * 112 | * This method is able to deal with both UNIX and Windows paths. 113 | */ 114 | public static function normalize(string $path): string 115 | { 116 | return str_replace('\\', '/', $path); 117 | } 118 | 119 | /** 120 | * Returns the directory part of the path. 121 | * 122 | * This method is similar to PHP's dirname(), but handles various cases 123 | * where dirname() returns a weird result: 124 | * 125 | * - dirname() does not accept backslashes on UNIX 126 | * - dirname("C:/symfony") returns "C:", not "C:/" 127 | * - dirname("C:/") returns ".", not "C:/" 128 | * - dirname("C:") returns ".", not "C:/" 129 | * - dirname("symfony") returns ".", not "" 130 | * - dirname() does not canonicalize the result 131 | * 132 | * This method fixes these shortcomings and behaves like dirname() 133 | * otherwise. 134 | * 135 | * The result is a canonical path. 136 | * 137 | * @return string The canonical directory part. Returns the root directory 138 | * if the root directory is passed. Returns an empty string 139 | * if a relative path is passed that contains no slashes. 140 | * Returns an empty string if an empty string is passed. 141 | */ 142 | public static function getDirectory(string $path): string 143 | { 144 | if ('' === $path) { 145 | return ''; 146 | } 147 | 148 | $path = self::canonicalize($path); 149 | 150 | // Maintain scheme 151 | if (false !== $schemeSeparatorPosition = strpos($path, '://')) { 152 | $scheme = substr($path, 0, $schemeSeparatorPosition + 3); 153 | $path = substr($path, $schemeSeparatorPosition + 3); 154 | } else { 155 | $scheme = ''; 156 | } 157 | 158 | if (false === $dirSeparatorPosition = strrpos($path, '/')) { 159 | return ''; 160 | } 161 | 162 | // Directory equals root directory "/" 163 | if (0 === $dirSeparatorPosition) { 164 | return $scheme.'/'; 165 | } 166 | 167 | // Directory equals Windows root "C:/" 168 | if (2 === $dirSeparatorPosition && ctype_alpha($path[0]) && ':' === $path[1]) { 169 | return $scheme.substr($path, 0, 3); 170 | } 171 | 172 | return $scheme.substr($path, 0, $dirSeparatorPosition); 173 | } 174 | 175 | /** 176 | * Returns canonical path of the user's home directory. 177 | * 178 | * Supported operating systems: 179 | * 180 | * - UNIX 181 | * - Windows8 and upper 182 | * 183 | * If your operating system or environment isn't supported, an exception is thrown. 184 | * 185 | * The result is a canonical path. 186 | * 187 | * @throws RuntimeException If your operating system or environment isn't supported 188 | */ 189 | public static function getHomeDirectory(): string 190 | { 191 | // For UNIX support 192 | if (getenv('HOME')) { 193 | return self::canonicalize(getenv('HOME')); 194 | } 195 | 196 | // For >= Windows8 support 197 | if (getenv('HOMEDRIVE') && getenv('HOMEPATH')) { 198 | return self::canonicalize(getenv('HOMEDRIVE').getenv('HOMEPATH')); 199 | } 200 | 201 | throw new RuntimeException("Cannot find the home directory path: Your environment or operating system isn't supported."); 202 | } 203 | 204 | /** 205 | * Returns the root directory of a path. 206 | * 207 | * The result is a canonical path. 208 | * 209 | * @return string The canonical root directory. Returns an empty string if 210 | * the given path is relative or empty. 211 | */ 212 | public static function getRoot(string $path): string 213 | { 214 | if ('' === $path) { 215 | return ''; 216 | } 217 | 218 | // Maintain scheme 219 | if (false !== $schemeSeparatorPosition = strpos($path, '://')) { 220 | $scheme = substr($path, 0, $schemeSeparatorPosition + 3); 221 | $path = substr($path, $schemeSeparatorPosition + 3); 222 | } else { 223 | $scheme = ''; 224 | } 225 | 226 | $firstCharacter = $path[0]; 227 | 228 | // UNIX root "/" or "\" (Windows style) 229 | if ('/' === $firstCharacter || '\\' === $firstCharacter) { 230 | return $scheme.'/'; 231 | } 232 | 233 | $length = \strlen($path); 234 | 235 | // Windows root 236 | if ($length > 1 && ':' === $path[1] && ctype_alpha($firstCharacter)) { 237 | // Special case: "C:" 238 | if (2 === $length) { 239 | return $scheme.$path.'/'; 240 | } 241 | 242 | // Normal case: "C:/ or "C:\" 243 | if ('/' === $path[2] || '\\' === $path[2]) { 244 | return $scheme.$firstCharacter.$path[1].'/'; 245 | } 246 | } 247 | 248 | return ''; 249 | } 250 | 251 | /** 252 | * Returns the file name without the extension from a file path. 253 | * 254 | * @param string|null $extension if specified, only that extension is cut 255 | * off (may contain leading dot) 256 | */ 257 | public static function getFilenameWithoutExtension(string $path, ?string $extension = null): string 258 | { 259 | if ('' === $path) { 260 | return ''; 261 | } 262 | 263 | if (null !== $extension) { 264 | // remove extension and trailing dot 265 | return rtrim(basename($path, $extension), '.'); 266 | } 267 | 268 | return pathinfo($path, \PATHINFO_FILENAME); 269 | } 270 | 271 | /** 272 | * Returns the extension from a file path (without leading dot). 273 | * 274 | * @param bool $forceLowerCase forces the extension to be lower-case 275 | */ 276 | public static function getExtension(string $path, bool $forceLowerCase = false): string 277 | { 278 | if ('' === $path) { 279 | return ''; 280 | } 281 | 282 | $extension = pathinfo($path, \PATHINFO_EXTENSION); 283 | 284 | if ($forceLowerCase) { 285 | $extension = self::toLower($extension); 286 | } 287 | 288 | return $extension; 289 | } 290 | 291 | /** 292 | * Returns whether the path has an (or the specified) extension. 293 | * 294 | * @param string $path the path string 295 | * @param string|string[]|null $extensions if null or not provided, checks if 296 | * an extension exists, otherwise 297 | * checks for the specified extension 298 | * or array of extensions (with or 299 | * without leading dot) 300 | * @param bool $ignoreCase whether to ignore case-sensitivity 301 | */ 302 | public static function hasExtension(string $path, $extensions = null, bool $ignoreCase = false): bool 303 | { 304 | if ('' === $path) { 305 | return false; 306 | } 307 | 308 | $actualExtension = self::getExtension($path, $ignoreCase); 309 | 310 | // Only check if path has any extension 311 | if ([] === $extensions || null === $extensions) { 312 | return '' !== $actualExtension; 313 | } 314 | 315 | if (\is_string($extensions)) { 316 | $extensions = [$extensions]; 317 | } 318 | 319 | foreach ($extensions as $key => $extension) { 320 | if ($ignoreCase) { 321 | $extension = self::toLower($extension); 322 | } 323 | 324 | // remove leading '.' in extensions array 325 | $extensions[$key] = ltrim($extension, '.'); 326 | } 327 | 328 | return \in_array($actualExtension, $extensions, true); 329 | } 330 | 331 | /** 332 | * Changes the extension of a path string. 333 | * 334 | * @param string $path The path string with filename.ext to change. 335 | * @param string $extension new extension (with or without leading dot) 336 | * 337 | * @return string the path string with new file extension 338 | */ 339 | public static function changeExtension(string $path, string $extension): string 340 | { 341 | if ('' === $path) { 342 | return ''; 343 | } 344 | 345 | $actualExtension = self::getExtension($path); 346 | $extension = ltrim($extension, '.'); 347 | 348 | // No extension for paths 349 | if (str_ends_with($path, '/')) { 350 | return $path; 351 | } 352 | 353 | // No actual extension in path 354 | if (!$actualExtension) { 355 | return $path.(str_ends_with($path, '.') ? '' : '.').$extension; 356 | } 357 | 358 | return substr($path, 0, -\strlen($actualExtension)).$extension; 359 | } 360 | 361 | public static function isAbsolute(string $path): bool 362 | { 363 | if ('' === $path) { 364 | return false; 365 | } 366 | 367 | // Strip scheme 368 | if (false !== ($schemeSeparatorPosition = strpos($path, '://')) && 1 !== $schemeSeparatorPosition) { 369 | $path = substr($path, $schemeSeparatorPosition + 3); 370 | } 371 | 372 | $firstCharacter = $path[0]; 373 | 374 | // UNIX root "/" or "\" (Windows style) 375 | if ('/' === $firstCharacter || '\\' === $firstCharacter) { 376 | return true; 377 | } 378 | 379 | // Windows root 380 | if (\strlen($path) > 1 && ctype_alpha($firstCharacter) && ':' === $path[1]) { 381 | // Special case: "C:" 382 | if (2 === \strlen($path)) { 383 | return true; 384 | } 385 | 386 | // Normal case: "C:/ or "C:\" 387 | if ('/' === $path[2] || '\\' === $path[2]) { 388 | return true; 389 | } 390 | } 391 | 392 | return false; 393 | } 394 | 395 | public static function isRelative(string $path): bool 396 | { 397 | return !self::isAbsolute($path); 398 | } 399 | 400 | /** 401 | * Turns a relative path into an absolute path in canonical form. 402 | * 403 | * Usually, the relative path is appended to the given base path. Dot 404 | * segments ("." and "..") are removed/collapsed and all slashes turned 405 | * into forward slashes. 406 | * 407 | * ```php 408 | * echo Path::makeAbsolute("../style.css", "/symfony/puli/css"); 409 | * // => /symfony/puli/style.css 410 | * ``` 411 | * 412 | * If an absolute path is passed, that path is returned unless its root 413 | * directory is different than the one of the base path. In that case, an 414 | * exception is thrown. 415 | * 416 | * ```php 417 | * Path::makeAbsolute("/style.css", "/symfony/puli/css"); 418 | * // => /style.css 419 | * 420 | * Path::makeAbsolute("C:/style.css", "C:/symfony/puli/css"); 421 | * // => C:/style.css 422 | * 423 | * Path::makeAbsolute("C:/style.css", "/symfony/puli/css"); 424 | * // InvalidArgumentException 425 | * ``` 426 | * 427 | * If the base path is not an absolute path, an exception is thrown. 428 | * 429 | * The result is a canonical path. 430 | * 431 | * @param string $basePath an absolute base path 432 | * 433 | * @throws InvalidArgumentException if the base path is not absolute or if 434 | * the given path is an absolute path with 435 | * a different root than the base path 436 | */ 437 | public static function makeAbsolute(string $path, string $basePath): string 438 | { 439 | if ('' === $basePath) { 440 | throw new InvalidArgumentException(\sprintf('The base path must be a non-empty string. Got: "%s".', $basePath)); 441 | } 442 | 443 | if (!self::isAbsolute($basePath)) { 444 | throw new InvalidArgumentException(\sprintf('The base path "%s" is not an absolute path.', $basePath)); 445 | } 446 | 447 | if (self::isAbsolute($path)) { 448 | return self::canonicalize($path); 449 | } 450 | 451 | if (false !== $schemeSeparatorPosition = strpos($basePath, '://')) { 452 | $scheme = substr($basePath, 0, $schemeSeparatorPosition + 3); 453 | $basePath = substr($basePath, $schemeSeparatorPosition + 3); 454 | } else { 455 | $scheme = ''; 456 | } 457 | 458 | return $scheme.self::canonicalize(rtrim($basePath, '/\\').'/'.$path); 459 | } 460 | 461 | /** 462 | * Turns a path into a relative path. 463 | * 464 | * The relative path is created relative to the given base path: 465 | * 466 | * ```php 467 | * echo Path::makeRelative("/symfony/style.css", "/symfony/puli"); 468 | * // => ../style.css 469 | * ``` 470 | * 471 | * If a relative path is passed and the base path is absolute, the relative 472 | * path is returned unchanged: 473 | * 474 | * ```php 475 | * Path::makeRelative("style.css", "/symfony/puli/css"); 476 | * // => style.css 477 | * ``` 478 | * 479 | * If both paths are relative, the relative path is created with the 480 | * assumption that both paths are relative to the same directory: 481 | * 482 | * ```php 483 | * Path::makeRelative("style.css", "symfony/puli/css"); 484 | * // => ../../../style.css 485 | * ``` 486 | * 487 | * If both paths are absolute, their root directory must be the same, 488 | * otherwise an exception is thrown: 489 | * 490 | * ```php 491 | * Path::makeRelative("C:/symfony/style.css", "/symfony/puli"); 492 | * // InvalidArgumentException 493 | * ``` 494 | * 495 | * If the passed path is absolute, but the base path is not, an exception 496 | * is thrown as well: 497 | * 498 | * ```php 499 | * Path::makeRelative("/symfony/style.css", "symfony/puli"); 500 | * // InvalidArgumentException 501 | * ``` 502 | * 503 | * If the base path is not an absolute path, an exception is thrown. 504 | * 505 | * The result is a canonical path. 506 | * 507 | * @throws InvalidArgumentException if the base path is not absolute or if 508 | * the given path has a different root 509 | * than the base path 510 | */ 511 | public static function makeRelative(string $path, string $basePath): string 512 | { 513 | $path = self::canonicalize($path); 514 | $basePath = self::canonicalize($basePath); 515 | 516 | [$root, $relativePath] = self::split($path); 517 | [$baseRoot, $relativeBasePath] = self::split($basePath); 518 | 519 | // If the base path is given as absolute path and the path is already 520 | // relative, consider it to be relative to the given absolute path 521 | // already 522 | if ('' === $root && '' !== $baseRoot) { 523 | // If base path is already in its root 524 | if ('' === $relativeBasePath) { 525 | $relativePath = ltrim($relativePath, './\\'); 526 | } 527 | 528 | return $relativePath; 529 | } 530 | 531 | // If the passed path is absolute, but the base path is not, we 532 | // cannot generate a relative path 533 | if ('' !== $root && '' === $baseRoot) { 534 | 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)); 535 | } 536 | 537 | // Fail if the roots of the two paths are different 538 | if ($baseRoot && $root !== $baseRoot) { 539 | 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)); 540 | } 541 | 542 | if ('' === $relativeBasePath) { 543 | return $relativePath; 544 | } 545 | 546 | // Build a "../../" prefix with as many "../" parts as necessary 547 | $parts = explode('/', $relativePath); 548 | $baseParts = explode('/', $relativeBasePath); 549 | $dotDotPrefix = ''; 550 | 551 | // Once we found a non-matching part in the prefix, we need to add 552 | // "../" parts for all remaining parts 553 | $match = true; 554 | 555 | foreach ($baseParts as $index => $basePart) { 556 | if ($match && isset($parts[$index]) && $basePart === $parts[$index]) { 557 | unset($parts[$index]); 558 | 559 | continue; 560 | } 561 | 562 | $match = false; 563 | $dotDotPrefix .= '../'; 564 | } 565 | 566 | return rtrim($dotDotPrefix.implode('/', $parts), '/'); 567 | } 568 | 569 | /** 570 | * Returns whether the given path is on the local filesystem. 571 | */ 572 | public static function isLocal(string $path): bool 573 | { 574 | return '' !== $path && !str_contains($path, '://'); 575 | } 576 | 577 | /** 578 | * Returns the longest common base path in canonical form of a set of paths or 579 | * `null` if the paths are on different Windows partitions. 580 | * 581 | * Dot segments ("." and "..") are removed/collapsed and all slashes turned 582 | * into forward slashes. 583 | * 584 | * ```php 585 | * $basePath = Path::getLongestCommonBasePath( 586 | * '/symfony/css/style.css', 587 | * '/symfony/css/..' 588 | * ); 589 | * // => /symfony 590 | * ``` 591 | * 592 | * The root is returned if no common base path can be found: 593 | * 594 | * ```php 595 | * $basePath = Path::getLongestCommonBasePath( 596 | * '/symfony/css/style.css', 597 | * '/puli/css/..' 598 | * ); 599 | * // => / 600 | * ``` 601 | * 602 | * If the paths are located on different Windows partitions, `null` is 603 | * returned. 604 | * 605 | * ```php 606 | * $basePath = Path::getLongestCommonBasePath( 607 | * 'C:/symfony/css/style.css', 608 | * 'D:/symfony/css/..' 609 | * ); 610 | * // => null 611 | * ``` 612 | */ 613 | public static function getLongestCommonBasePath(string ...$paths): ?string 614 | { 615 | [$bpRoot, $basePath] = self::split(self::canonicalize(reset($paths))); 616 | 617 | for (next($paths); null !== key($paths) && '' !== $basePath; next($paths)) { 618 | [$root, $path] = self::split(self::canonicalize(current($paths))); 619 | 620 | // If we deal with different roots (e.g. C:/ vs. D:/), it's time 621 | // to quit 622 | if ($root !== $bpRoot) { 623 | return null; 624 | } 625 | 626 | // Make the base path shorter until it fits into path 627 | while (true) { 628 | if ('.' === $basePath) { 629 | // No more base paths 630 | $basePath = ''; 631 | 632 | // next path 633 | continue 2; 634 | } 635 | 636 | // Prevent false positives for common prefixes 637 | // see isBasePath() 638 | if (str_starts_with($path.'/', $basePath.'/')) { 639 | // next path 640 | continue 2; 641 | } 642 | 643 | $basePath = \dirname($basePath); 644 | } 645 | } 646 | 647 | return $bpRoot.$basePath; 648 | } 649 | 650 | /** 651 | * Joins two or more path strings into a canonical path. 652 | */ 653 | public static function join(string ...$paths): string 654 | { 655 | $finalPath = null; 656 | $wasScheme = false; 657 | 658 | foreach ($paths as $path) { 659 | if ('' === $path) { 660 | continue; 661 | } 662 | 663 | if (null === $finalPath) { 664 | // For first part we keep slashes, like '/top', 'C:\' or 'phar://' 665 | $finalPath = $path; 666 | $wasScheme = str_contains($path, '://'); 667 | continue; 668 | } 669 | 670 | // Only add slash if previous part didn't end with '/' or '\' 671 | if (!\in_array(substr($finalPath, -1), ['/', '\\'], true)) { 672 | $finalPath .= '/'; 673 | } 674 | 675 | // If first part included a scheme like 'phar://' we allow \current part to start with '/', otherwise trim 676 | $finalPath .= $wasScheme ? $path : ltrim($path, '/'); 677 | $wasScheme = false; 678 | } 679 | 680 | if (null === $finalPath) { 681 | return ''; 682 | } 683 | 684 | return self::canonicalize($finalPath); 685 | } 686 | 687 | /** 688 | * Returns whether a path is a base path of another path. 689 | * 690 | * Dot segments ("." and "..") are removed/collapsed and all slashes turned 691 | * into forward slashes. 692 | * 693 | * ```php 694 | * Path::isBasePath('/symfony', '/symfony/css'); 695 | * // => true 696 | * 697 | * Path::isBasePath('/symfony', '/symfony'); 698 | * // => true 699 | * 700 | * Path::isBasePath('/symfony', '/symfony/..'); 701 | * // => false 702 | * 703 | * Path::isBasePath('/symfony', '/puli'); 704 | * // => false 705 | * ``` 706 | */ 707 | public static function isBasePath(string $basePath, string $ofPath): bool 708 | { 709 | $basePath = self::canonicalize($basePath); 710 | $ofPath = self::canonicalize($ofPath); 711 | 712 | // Append slashes to prevent false positives when two paths have 713 | // a common prefix, for example /base/foo and /base/foobar. 714 | // Don't append a slash for the root "/", because then that root 715 | // won't be discovered as common prefix ("//" is not a prefix of 716 | // "/foobar/"). 717 | return str_starts_with($ofPath.'/', rtrim($basePath, '/').'/'); 718 | } 719 | 720 | /** 721 | * @return string[] 722 | */ 723 | private static function findCanonicalParts(string $root, string $pathWithoutRoot): array 724 | { 725 | $parts = explode('/', $pathWithoutRoot); 726 | 727 | $canonicalParts = []; 728 | 729 | // Collapse "." and "..", if possible 730 | foreach ($parts as $part) { 731 | if ('.' === $part || '' === $part) { 732 | continue; 733 | } 734 | 735 | // Collapse ".." with the previous part, if one exists 736 | // Don't collapse ".." if the previous part is also ".." 737 | if ('..' === $part && \count($canonicalParts) > 0 && '..' !== $canonicalParts[\count($canonicalParts) - 1]) { 738 | array_pop($canonicalParts); 739 | 740 | continue; 741 | } 742 | 743 | // Only add ".." prefixes for relative paths 744 | if ('..' !== $part || '' === $root) { 745 | $canonicalParts[] = $part; 746 | } 747 | } 748 | 749 | return $canonicalParts; 750 | } 751 | 752 | /** 753 | * Splits a canonical path into its root directory and the remainder. 754 | * 755 | * If the path has no root directory, an empty root directory will be 756 | * returned. 757 | * 758 | * If the root directory is a Windows style partition, the resulting root 759 | * will always contain a trailing slash. 760 | * 761 | * list ($root, $path) = Path::split("C:/symfony") 762 | * // => ["C:/", "symfony"] 763 | * 764 | * list ($root, $path) = Path::split("C:") 765 | * // => ["C:/", ""] 766 | * 767 | * @return array{string, string} an array with the root directory and the remaining relative path 768 | */ 769 | private static function split(string $path): array 770 | { 771 | if ('' === $path) { 772 | return ['', '']; 773 | } 774 | 775 | // Remember scheme as part of the root, if any 776 | if (false !== $schemeSeparatorPosition = strpos($path, '://')) { 777 | $root = substr($path, 0, $schemeSeparatorPosition + 3); 778 | $path = substr($path, $schemeSeparatorPosition + 3); 779 | } else { 780 | $root = ''; 781 | } 782 | 783 | $length = \strlen($path); 784 | 785 | // Remove and remember root directory 786 | if (str_starts_with($path, '/')) { 787 | $root .= '/'; 788 | $path = $length > 1 ? substr($path, 1) : ''; 789 | } elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) { 790 | if (2 === $length) { 791 | // Windows special case: "C:" 792 | $root .= $path.'/'; 793 | $path = ''; 794 | } elseif ('/' === $path[2]) { 795 | // Windows normal case: "C:/".. 796 | $root .= substr($path, 0, 3); 797 | $path = $length > 3 ? substr($path, 3) : ''; 798 | } 799 | } 800 | 801 | return [$root, $path]; 802 | } 803 | 804 | private static function toLower(string $string): string 805 | { 806 | if (false !== $encoding = mb_detect_encoding($string, null, true)) { 807 | return mb_strtolower($string, $encoding); 808 | } 809 | 810 | return strtolower($string); 811 | } 812 | 813 | private function __construct() 814 | { 815 | } 816 | } 817 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Filesystem Component 2 | ==================== 3 | 4 | The Filesystem component provides basic utilities for the filesystem. 5 | 6 | Resources 7 | --------- 8 | 9 | * [Documentation](https://symfony.com/doc/current/components/filesystem.html) 10 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 11 | * [Report issues](https://github.com/symfony/symfony/issues) and 12 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 13 | in the [main Symfony repository](https://github.com/symfony/symfony) 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/filesystem", 3 | "type": "library", 4 | "description": "Provides basic utilities for the filesystem", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/polyfill-ctype": "~1.8", 21 | "symfony/polyfill-mbstring": "~1.8" 22 | }, 23 | "require-dev": { 24 | "symfony/process": "^6.4|^7.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { "Symfony\\Component\\Filesystem\\": "" }, 28 | "exclude-from-classmap": [ 29 | "/Tests/" 30 | ] 31 | }, 32 | "minimum-stability": "dev" 33 | } 34 | --------------------------------------------------------------------------------