diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb7170ca..80818d1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +7.1 +--- + + * Add the `Filesystem::readFile()` method + +7.0 +--- + + * Add argument `$lock` to `Filesystem::appendToFile()` + 5.4 --- diff --git a/Exception/FileNotFoundException.php b/Exception/FileNotFoundException.php index 06b732b16..d0d27977d 100644 --- a/Exception/FileNotFoundException.php +++ b/Exception/FileNotFoundException.php @@ -25,7 +25,7 @@ public function __construct(?string $message = null, int $code = 0, ?\Throwable if (null === $path) { $message = 'File could not be found.'; } else { - $message = sprintf('File "%s" could not be found.', $path); + $message = \sprintf('File "%s" could not be found.', $path); } } diff --git a/Exception/IOException.php b/Exception/IOException.php index 44254a819..46ab8b4a5 100644 --- a/Exception/IOException.php +++ b/Exception/IOException.php @@ -20,19 +20,16 @@ */ class IOException extends \RuntimeException implements IOExceptionInterface { - private $path; - - public function __construct(string $message, int $code = 0, ?\Throwable $previous = null, ?string $path = null) - { - $this->path = $path; - + public function __construct( + string $message, + int $code = 0, + ?\Throwable $previous = null, + private ?string $path = null, + ) { parent::__construct($message, $code, $previous); } - /** - * {@inheritdoc} - */ - public function getPath() + public function getPath(): ?string { return $this->path; } diff --git a/Exception/IOExceptionInterface.php b/Exception/IOExceptionInterface.php index 42829ab6c..90c71db88 100644 --- a/Exception/IOExceptionInterface.php +++ b/Exception/IOExceptionInterface.php @@ -20,8 +20,6 @@ interface IOExceptionInterface extends ExceptionInterface { /** * Returns the associated path for the exception. - * - * @return string|null */ - public function getPath(); + public function getPath(): ?string; } diff --git a/Filesystem.php b/Filesystem.php index 358a74b37..f97c8b2fe 100644 --- a/Filesystem.php +++ b/Filesystem.php @@ -22,7 +22,7 @@ */ class Filesystem { - private static $lastError; + private static ?string $lastError = null; /** * Copies a file. @@ -34,11 +34,11 @@ class Filesystem * @throws FileNotFoundException When originFile doesn't exist * @throws IOException When copy fails */ - public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false) + public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false): void { $originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://'); if ($originIsLocal && !is_file($originFile)) { - throw new FileNotFoundException(sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile); + throw new FileNotFoundException(\sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile); } $this->mkdir(\dirname($targetFile)); @@ -51,12 +51,12 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe if ($doCopy) { // https://bugs.php.net/64634 if (!$source = self::box('fopen', $originFile, 'r')) { - 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); + 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); } // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) { - 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); + 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); } $bytesCopied = stream_copy_to_stream($source, $target); @@ -65,7 +65,7 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe unset($source, $target); if (!is_file($targetFile)) { - throw new IOException(sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile); + throw new IOException(\sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile); } if ($originIsLocal) { @@ -76,7 +76,7 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe self::box('touch', $targetFile, filemtime($originFile)); if ($bytesCopied !== $bytesOrigin = filesize($originFile)) { - 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); + 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); } } } @@ -85,11 +85,9 @@ public function copy(string $originFile, string $targetFile, bool $overwriteNewe /** * Creates a directory recursively. * - * @param string|iterable $dirs The directory path - * * @throws IOException On any directory creation failure */ - public function mkdir($dirs, int $mode = 0777) + public function mkdir(string|iterable $dirs, int $mode = 0777): void { foreach ($this->toIterable($dirs) as $dir) { if (is_dir($dir)) { @@ -97,25 +95,21 @@ public function mkdir($dirs, int $mode = 0777) } if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) { - throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); + throw new IOException(\sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); } } } /** * Checks the existence of files or directories. - * - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to check - * - * @return bool */ - public function exists($files) + public function exists(string|iterable $files): bool { $maxPathLength = \PHP_MAXPATHLEN - 2; foreach ($this->toIterable($files) as $file) { if (\strlen($file) > $maxPathLength) { - throw new IOException(sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file); + throw new IOException(\sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file); } if (!file_exists($file)) { @@ -129,17 +123,16 @@ public function exists($files) /** * Sets access and modification time of file. * - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to create - * @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used - * @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used + * @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used + * @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used * * @throws IOException When touch fails */ - public function touch($files, ?int $time = null, ?int $atime = null) + public function touch(string|iterable $files, ?int $time = null, ?int $atime = null): void { foreach ($this->toIterable($files) as $file) { if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) { - throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); + throw new IOException(\sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); } } } @@ -147,11 +140,9 @@ public function touch($files, ?int $time = null, ?int $atime = null) /** * Removes files or directories. * - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove - * * @throws IOException When removal fails */ - public function remove($files) + public function remove(string|iterable $files): void { if ($files instanceof \Traversable) { $files = iterator_to_array($files, false); @@ -169,7 +160,7 @@ private static function doRemove(array $files, bool $isRecursive): void if (is_link($file)) { // See https://bugs.php.net/52176 if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) { - throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); + throw new IOException(\sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); } } elseif (is_dir($file)) { if (!$isRecursive) { @@ -178,7 +169,7 @@ private static function doRemove(array $files, bool $isRecursive): void if (file_exists($tmpName)) { try { self::doRemove([$tmpName], true); - } catch (IOException $e) { + } catch (IOException) { } } @@ -190,8 +181,8 @@ private static function doRemove(array $files, bool $isRecursive): void } } - $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); - self::doRemove(iterator_to_array($files, true), true); + $filesystemIterator = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); + self::doRemove(iterator_to_array($filesystemIterator, true), true); if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { $lastError = self::$lastError; @@ -200,10 +191,10 @@ private static function doRemove(array $files, bool $isRecursive): void $file = $origFile; } - throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError); + throw new IOException(\sprintf('Failed to remove directory "%s": ', $file).$lastError); } } elseif (!self::box('unlink', $file) && ((self::$lastError && str_contains(self::$lastError, 'Permission denied')) || file_exists($file))) { - throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError); + throw new IOException(\sprintf('Failed to remove file "%s": ', $file).self::$lastError); } } } @@ -211,18 +202,17 @@ private static function doRemove(array $files, bool $isRecursive): void /** * Change mode for an array of files or directories. * - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change mode - * @param int $mode The new mode (octal) - * @param int $umask The mode mask (octal) - * @param bool $recursive Whether change the mod recursively or not + * @param int $mode The new mode (octal) + * @param int $umask The mode mask (octal) + * @param bool $recursive Whether change the mod recursively or not * * @throws IOException When the change fails */ - public function chmod($files, int $mode, int $umask = 0000, bool $recursive = false) + public function chmod(string|iterable $files, int $mode, int $umask = 0000, bool $recursive = false): void { foreach ($this->toIterable($files) as $file) { - if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && !self::box('chmod', $file, $mode & ~$umask)) { - throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); + if (!self::box('chmod', $file, $mode & ~$umask)) { + throw new IOException(\sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); } if ($recursive && is_dir($file) && !is_link($file)) { $this->chmod(new \FilesystemIterator($file), $mode, $umask, true); @@ -237,13 +227,12 @@ public function chmod($files, int $mode, int $umask = 0000, bool $recursive = fa * * @see https://www.php.net/chown * - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change owner - * @param string|int $user A user name or number - * @param bool $recursive Whether change the owner recursively or not + * @param string|int $user A user name or number + * @param bool $recursive Whether change the owner recursively or not * * @throws IOException When the change fails */ - public function chown($files, $user, bool $recursive = false) + public function chown(string|iterable $files, string|int $user, bool $recursive = false): void { foreach ($this->toIterable($files) as $file) { if ($recursive && is_dir($file) && !is_link($file)) { @@ -251,11 +240,11 @@ public function chown($files, $user, bool $recursive = false) } if (is_link($file) && \function_exists('lchown')) { if (!self::box('lchown', $file, $user)) { - throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); + throw new IOException(\sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); } } else { if (!self::box('chown', $file, $user)) { - throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); + throw new IOException(\sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); } } } @@ -268,13 +257,12 @@ public function chown($files, $user, bool $recursive = false) * * @see https://www.php.net/chgrp * - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change group - * @param string|int $group A group name or number - * @param bool $recursive Whether change the group recursively or not + * @param string|int $group A group name or number + * @param bool $recursive Whether change the group recursively or not * * @throws IOException When the change fails */ - public function chgrp($files, $group, bool $recursive = false) + public function chgrp(string|iterable $files, string|int $group, bool $recursive = false): void { foreach ($this->toIterable($files) as $file) { if ($recursive && is_dir($file) && !is_link($file)) { @@ -282,11 +270,11 @@ public function chgrp($files, $group, bool $recursive = false) } if (is_link($file) && \function_exists('lchgrp')) { if (!self::box('lchgrp', $file, $group)) { - throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); + throw new IOException(\sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); } } else { if (!self::box('chgrp', $file, $group)) { - throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); + throw new IOException(\sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); } } } @@ -298,11 +286,11 @@ public function chgrp($files, $group, bool $recursive = false) * @throws IOException When target file or directory already exists * @throws IOException When origin cannot be renamed */ - public function rename(string $origin, string $target, bool $overwrite = false) + public function rename(string $origin, string $target, bool $overwrite = false): void { // we check that target does not exist if (!$overwrite && $this->isReadable($target)) { - throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); + throw new IOException(\sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); } if (!self::box('rename', $origin, $target)) { @@ -313,7 +301,7 @@ public function rename(string $origin, string $target, bool $overwrite = false) return; } - throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); + throw new IOException(\sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); } } @@ -327,7 +315,7 @@ private function isReadable(string $filename): bool $maxPathLength = \PHP_MAXPATHLEN - 2; if (\strlen($filename) > $maxPathLength) { - throw new IOException(sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename); + throw new IOException(\sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename); } return is_readable($filename); @@ -338,7 +326,7 @@ private function isReadable(string $filename): bool * * @throws IOException When symlink fails */ - public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false) + public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false): void { self::assertFunctionExists('symlink'); @@ -375,7 +363,7 @@ public function symlink(string $originDir, string $targetDir, bool $copyOnWindow * @throws FileNotFoundException When original file is missing or not a file * @throws IOException When link fails, including if link already exists */ - public function hardlink(string $originFile, $targetFiles) + public function hardlink(string $originFile, string|iterable $targetFiles): void { self::assertFunctionExists('link'); @@ -384,7 +372,7 @@ public function hardlink(string $originFile, $targetFiles) } if (!is_file($originFile)) { - throw new FileNotFoundException(sprintf('Origin file "%s" is not a file.', $originFile)); + throw new FileNotFoundException(\sprintf('Origin file "%s" is not a file.', $originFile)); } foreach ($this->toIterable($targetFiles) as $targetFile) { @@ -404,14 +392,14 @@ public function hardlink(string $originFile, $targetFiles) /** * @param string $linkType Name of the link type, typically 'symbolic' or 'hard' */ - private function linkException(string $origin, string $target, string $linkType) + private function linkException(string $origin, string $target, string $linkType): never { if (self::$lastError) { if ('\\' === \DIRECTORY_SEPARATOR && str_contains(self::$lastError, 'error code(1314)')) { - 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); + 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); } } - throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); + throw new IOException(\sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); } /** @@ -424,10 +412,8 @@ private function linkException(string $origin, string $target, string $linkType) * With $canonicalize = true * - if $path does not exist, returns null * - if $path exists, returns its absolute fully resolved final version - * - * @return string|null */ - public function readlink(string $path, bool $canonicalize = false) + public function readlink(string $path, bool $canonicalize = false): ?string { if (!$canonicalize && !is_link($path)) { return null; @@ -438,14 +424,6 @@ public function readlink(string $path, bool $canonicalize = false) return null; } - if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70410) { - $path = readlink($path); - } - - return realpath($path); - } - - if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) { return realpath($path); } @@ -454,17 +432,15 @@ public function readlink(string $path, bool $canonicalize = false) /** * Given an existing path, convert it to a path relative to a given starting path. - * - * @return string */ - public function makePathRelative(string $endPath, string $startPath) + public function makePathRelative(string $endPath, string $startPath): string { if (!$this->isAbsolutePath($startPath)) { - throw new InvalidArgumentException(sprintf('The start path "%s" is not absolute.', $startPath)); + throw new InvalidArgumentException(\sprintf('The start path "%s" is not absolute.', $startPath)); } if (!$this->isAbsolutePath($endPath)) { - throw new InvalidArgumentException(sprintf('The end path "%s" is not absolute.', $endPath)); + throw new InvalidArgumentException(\sprintf('The end path "%s" is not absolute.', $endPath)); } // Normalize separators on Windows @@ -473,11 +449,9 @@ public function makePathRelative(string $endPath, string $startPath) $startPath = str_replace('\\', '/', $startPath); } - $splitDriveLetter = function ($path) { - return (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0])) - ? [substr($path, 2), strtoupper($path[0])] - : [$path, null]; - }; + $splitDriveLetter = fn ($path) => (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0])) + ? [substr($path, 2), strtoupper($path[0])] + : [$path, null]; $splitPath = function ($path) { $result = []; @@ -545,14 +519,14 @@ public function makePathRelative(string $endPath, string $startPath) * * @throws IOException When file type is unknown */ - public function mirror(string $originDir, string $targetDir, ?\Traversable $iterator = null, array $options = []) + public function mirror(string $originDir, string $targetDir, ?\Traversable $iterator = null, array $options = []): void { $targetDir = rtrim($targetDir, '/\\'); $originDir = rtrim($originDir, '/\\'); $originDirLen = \strlen($originDir); if (!$this->exists($originDir)) { - throw new IOException(sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir); + throw new IOException(\sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir); } // Iterate in destination folder to remove obsolete entries @@ -596,17 +570,15 @@ public function mirror(string $originDir, string $targetDir, ?\Traversable $iter } elseif (is_file($file)) { $this->copy($file, $target, $options['override'] ?? false); } else { - throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file); + throw new IOException(\sprintf('Unable to guess "%s" file type.', $file), 0, null, $file); } } } /** * Returns whether the file path is an absolute path. - * - * @return bool */ - public function isAbsolutePath(string $file) + public function isAbsolutePath(string $file): bool { return '' !== $file && (strspn($file, '/\\', 0, 1) || (\strlen($file) > 3 && ctype_alpha($file[0]) @@ -626,9 +598,8 @@ public function isAbsolutePath(string $file) * * @return string The new temporary filename (with path), or throw an exception on failure */ - public function tempnam(string $dir, string $prefix/* , string $suffix = '' */) + public function tempnam(string $dir, string $prefix, string $suffix = ''): string { - $suffix = \func_num_args() > 2 ? func_get_arg(2) : ''; [$scheme, $hierarchy] = $this->getSchemeAndHierarchy($dir); // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem @@ -648,7 +619,7 @@ public function tempnam(string $dir, string $prefix/* , string $suffix = '' */) // Loop until we create a valid temp file or have reached 10 attempts for ($i = 0; $i < 10; ++$i) { // Create a unique filename - $tmpFile = $dir.'/'.$prefix.uniqid(mt_rand(), true).$suffix; + $tmpFile = $dir.'/'.$prefix.bin2hex(random_bytes(4)).$suffix; // Use fopen instead of file_exists as some streams do not support stat // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability @@ -672,10 +643,10 @@ public function tempnam(string $dir, string $prefix/* , string $suffix = '' */) * * @throws IOException if the file cannot be written to */ - public function dumpFile(string $filename, $content) + public function dumpFile(string $filename, $content): void { if (\is_array($content)) { - throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); + throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); } $dir = \dirname($filename); @@ -696,7 +667,7 @@ public function dumpFile(string $filename, $content) try { if (false === self::box('file_put_contents', $tmpFile, $content)) { - throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); + throw new IOException(\sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); } self::box('chmod', $tmpFile, self::box('fileperms', $filename) ?: 0666 & ~umask()); @@ -721,10 +692,10 @@ public function dumpFile(string $filename, $content) * * @throws IOException If the file is not writable */ - public function appendToFile(string $filename, $content/* , bool $lock = false */) + public function appendToFile(string $filename, $content, bool $lock = false): void { if (\is_array($content)) { - throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); + throw new \TypeError(\sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); } $dir = \dirname($filename); @@ -733,14 +704,31 @@ public function appendToFile(string $filename, $content/* , bool $lock = false * $this->mkdir($dir); } - $lock = \func_num_args() > 2 && func_get_arg(2); - if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND | ($lock ? \LOCK_EX : 0))) { - throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); + throw new IOException(\sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); } } - private function toIterable($files): iterable + /** + * Returns the content of a file as a string. + * + * @throws IOException If the file cannot be read + */ + public function readFile(string $filename): string + { + if (is_dir($filename)) { + throw new IOException(\sprintf('Failed to read file "%s": File is a directory.', $filename)); + } + + $content = self::box('file_get_contents', $filename); + if (false === $content) { + throw new IOException(\sprintf('Failed to read file "%s": ', $filename).self::$lastError, 0, null, $filename); + } + + return $content; + } + + private function toIterable(string|iterable $files): iterable { return is_iterable($files) ? $files : [$files]; } @@ -758,21 +746,16 @@ private function getSchemeAndHierarchy(string $filename): array private static function assertFunctionExists(string $func): void { if (!\function_exists($func)) { - throw new IOException(sprintf('Unable to perform filesystem operation because the "%s()" function has been disabled.', $func)); + throw new IOException(\sprintf('Unable to perform filesystem operation because the "%s()" function has been disabled.', $func)); } } - /** - * @param mixed ...$args - * - * @return mixed - */ - private static function box(string $func, ...$args) + private static function box(string $func, mixed ...$args): mixed { self::assertFunctionExists($func); self::$lastError = null; - set_error_handler(__CLASS__.'::handleError'); + set_error_handler(self::handleError(...)); try { return $func(...$args); } finally { @@ -783,7 +766,7 @@ private static function box(string $func, ...$args) /** * @internal */ - public static function handleError(int $type, string $msg) + public static function handleError(int $type, string $msg): void { self::$lastError = $msg; } diff --git a/Path.php b/Path.php index eb6d8ea08..2f2e87903 100644 --- a/Path.php +++ b/Path.php @@ -42,12 +42,9 @@ final class Path * * @var array */ - private static $buffer = []; + private static array $buffer = []; - /** - * @var int - */ - private static $bufferSize = 0; + private static int $bufferSize = 0; /** * Canonicalizes the given path. @@ -349,13 +346,13 @@ public static function changeExtension(string $path, string $extension): string $extension = ltrim($extension, '.'); // No extension for paths - if ('/' === substr($path, -1)) { + if (str_ends_with($path, '/')) { return $path; } // No actual extension in path - if (empty($actualExtension)) { - return $path.('.' === substr($path, -1) ? '' : '.').$extension; + if (!$actualExtension) { + return $path.(str_ends_with($path, '.') ? '' : '.').$extension; } return substr($path, 0, -\strlen($actualExtension)).$extension; @@ -440,11 +437,11 @@ public static function isRelative(string $path): bool public static function makeAbsolute(string $path, string $basePath): string { if ('' === $basePath) { - throw new InvalidArgumentException(sprintf('The base path must be a non-empty string. Got: "%s".', $basePath)); + throw new InvalidArgumentException(\sprintf('The base path must be a non-empty string. Got: "%s".', $basePath)); } if (!self::isAbsolute($basePath)) { - throw new InvalidArgumentException(sprintf('The base path "%s" is not an absolute path.', $basePath)); + throw new InvalidArgumentException(\sprintf('The base path "%s" is not an absolute path.', $basePath)); } if (self::isAbsolute($path)) { @@ -534,12 +531,12 @@ public static function makeRelative(string $path, string $basePath): string // If the passed path is absolute, but the base path is not, we // cannot generate a relative path if ('' !== $root && '' === $baseRoot) { - 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)); + 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)); } // Fail if the roots of the two paths are different if ($baseRoot && $root !== $baseRoot) { - 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)); + 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)); } if ('' === $relativeBasePath) { @@ -574,7 +571,7 @@ public static function makeRelative(string $path, string $basePath): string */ public static function isLocal(string $path): bool { - return '' !== $path && false === strpos($path, '://'); + return '' !== $path && !str_contains($path, '://'); } /** @@ -638,7 +635,7 @@ public static function getLongestCommonBasePath(string ...$paths): ?string // Prevent false positives for common prefixes // see isBasePath() - if (0 === strpos($path.'/', $basePath.'/')) { + if (str_starts_with($path.'/', $basePath.'/')) { // next path continue 2; } @@ -666,12 +663,12 @@ public static function join(string ...$paths): string if (null === $finalPath) { // For first part we keep slashes, like '/top', 'C:\' or 'phar://' $finalPath = $path; - $wasScheme = (false !== strpos($path, '://')); + $wasScheme = str_contains($path, '://'); continue; } // Only add slash if previous part didn't end with '/' or '\' - if (!\in_array(substr($finalPath, -1), ['/', '\\'])) { + if (!\in_array(substr($finalPath, -1), ['/', '\\'], true)) { $finalPath .= '/'; } @@ -717,7 +714,7 @@ public static function isBasePath(string $basePath, string $ofPath): bool // Don't append a slash for the root "/", because then that root // won't be discovered as common prefix ("//" is not a prefix of // "/foobar/"). - return 0 === strpos($ofPath.'/', rtrim($basePath, '/').'/'); + return str_starts_with($ofPath.'/', rtrim($basePath, '/').'/'); } /** @@ -786,7 +783,7 @@ private static function split(string $path): array $length = \strlen($path); // Remove and remember root directory - if (0 === strpos($path, '/')) { + if (str_starts_with($path, '/')) { $root .= '/'; $path = $length > 1 ? substr($path, 1) : ''; } elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) { diff --git a/Tests/FilesystemTest.php b/Tests/FilesystemTest.php index d1722db93..4c3355c79 100644 --- a/Tests/FilesystemTest.php +++ b/Tests/FilesystemTest.php @@ -166,7 +166,7 @@ public function testCopyCreatesTargetDirectoryIfItDoesNotExist() public function testCopyForOriginUrlsAndExistingLocalFileDefaultsToCopy() { - if (!\in_array('http', stream_get_wrappers())) { + if (!\in_array('http', stream_get_wrappers(), true)) { $this->markTestSkipped('"http" stream wrapper is not enabled.'); } @@ -388,7 +388,7 @@ public function testRemoveCleansInvalidLinks() // create symlink to nonexistent dir rmdir($basePath.'dir'); - $this->assertFalse('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400 ? @readlink($basePath.'dir-link') : is_dir($basePath.'dir-link')); + $this->assertDirectoryDoesNotExist($basePath.'dir-link'); $this->filesystem->remove($basePath); @@ -863,7 +863,7 @@ public function testRenameOverwritesTheTargetIfItAlreadyExists() public function testRenameThrowsExceptionOnError() { $this->expectException(IOException::class); - $file = $this->workspace.\DIRECTORY_SEPARATOR.uniqid('fs_test_', true); + $file = $this->workspace.\DIRECTORY_SEPARATOR.'does-not-exist'; $newPath = $this->workspace.\DIRECTORY_SEPARATOR.'new_file'; $this->filesystem->rename($file, $newPath); @@ -1089,11 +1089,7 @@ public function testReadAbsoluteLink() $this->assertEquals($file, $this->filesystem->readlink($link1)); - if (!('\\' == \DIRECTORY_SEPARATOR && \PHP_MAJOR_VERSION === 7 && \PHP_MINOR_VERSION === 3)) { - // Skip for Windows with PHP 7.3.* - $this->assertEquals($link1, $this->filesystem->readlink($link2)); - } - + $this->assertEquals($link1, $this->filesystem->readlink($link2)); $this->assertEquals($file, $this->filesystem->readlink($link1, true)); $this->assertEquals($file, $this->filesystem->readlink($link2, true)); $this->assertEquals($file, $this->filesystem->readlink($file, true)); @@ -1103,10 +1099,6 @@ public function testReadBrokenLink() { $this->markAsSkippedIfSymlinkIsMissing(); - if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) { - $this->markTestSkipped('Windows does not support reading "broken" symlinks in PHP < 7.4.0'); - } - $file = Path::join($this->workspace, 'file'); $link = Path::join($this->workspace, 'link'); @@ -1637,11 +1629,6 @@ public function testDumpFileFollowsSymlink() $this->assertStringEqualsFile($linknameA, 'bar'); $this->assertStringEqualsFile($linknameB, 'bar'); - // Windows does not support reading "broken" symlinks in PHP < 7.4.0 - if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) { - return; - } - $this->filesystem->remove($filename); $this->filesystem->dumpFile($linknameB, 'baz'); @@ -1842,6 +1829,43 @@ public function testDumpFileCleansUpAfterFailure() $this->assertSame([$targetFile], glob($this->workspace.'/*')); } + public function testReadFile() + { + $licenseFile = \dirname(__DIR__).'/LICENSE'; + + $this->assertStringEqualsFile($licenseFile, $this->filesystem->readFile($licenseFile)); + } + + public function testReadNonExistentFile() + { + $this->expectException(IOException::class); + $this->expectExceptionMessageMatches(\sprintf('#^Failed to read file ".+%1$sTests/invalid"\\: file_get_contents\\(.+%1$sTests/invalid\\)\\: Failed to open stream\\: No such file or directory$#', preg_quote(\DIRECTORY_SEPARATOR))); + + $this->filesystem->readFile(__DIR__.'/invalid'); + } + + public function testReadDirectory() + { + $this->expectException(IOException::class); + $this->expectExceptionMessageMatches(\sprintf('#^Failed to read file ".+%sTests"\\: File is a directory\\.$#', preg_quote(\DIRECTORY_SEPARATOR))); + + $this->filesystem->readFile(__DIR__); + } + + public function testReadUnreadableFile() + { + $this->markAsSkippedIfChmodIsMissing(); + + $filename = $this->workspace.'/unreadable.txt'; + file_put_contents($filename, 'Hello World'); + chmod($filename, 0o000); + + $this->expectException(IOException::class); + $this->expectExceptionMessageMatches('#^Failed to read file ".+/unreadable.txt"\\: file_get_contents\\(.+/unreadable.txt\\)\\: Failed to open stream\\: Permission denied$#'); + + $this->filesystem->readFile($filename); + } + public function testCopyShouldKeepExecutionPermission() { $this->markAsSkippedIfChmodIsMissing(); @@ -1863,7 +1887,7 @@ public function testDumpToProtectedDirectory() $this->markTestSkipped('This test is specific to Windows.'); } - if (($userProfilePath = getenv('USERPROFILE')) === false || !is_dir($userProfilePath)) { + if (false === ($userProfilePath = getenv('USERPROFILE')) || !is_dir($userProfilePath)) { throw new \RuntimeException('Failed to retrieve user profile path.'); } diff --git a/Tests/FilesystemTestCase.php b/Tests/FilesystemTestCase.php index 725870c69..ce9176a47 100644 --- a/Tests/FilesystemTestCase.php +++ b/Tests/FilesystemTestCase.php @@ -16,29 +16,14 @@ class FilesystemTestCase extends TestCase { - private $umask; + protected array $longPathNamesWindows = []; + protected Filesystem $filesystem; + protected string $workspace; - protected $longPathNamesWindows = []; + private int $umask; - /** - * @var Filesystem - */ - protected $filesystem = null; - - /** - * @var string - */ - protected $workspace = null; - - /** - * @var bool|null Flag for hard links on Windows - */ - private static $linkOnWindows = null; - - /** - * @var bool|null Flag for symbolic links on Windows - */ - private static $symlinkOnWindows = null; + private static ?bool $linkOnWindows = null; + private static ?bool $symlinkOnWindows = null; public static function setUpBeforeClass(): void { @@ -80,7 +65,7 @@ protected function setUp(): void protected function tearDown(): void { - if (!empty($this->longPathNamesWindows)) { + if ($this->longPathNamesWindows) { foreach ($this->longPathNamesWindows as $path) { exec('DEL '.$path); } @@ -97,11 +82,11 @@ protected function tearDown(): void */ protected function assertFilePermissions($expectedFilePerms, $filePath) { - $actualFilePerms = (int) substr(sprintf('%o', fileperms($filePath)), -3); + $actualFilePerms = (int) substr(\sprintf('%o', fileperms($filePath)), -3); $this->assertEquals( $expectedFilePerms, $actualFilePerms, - sprintf('File permissions for %s must be %s. Actual %s', $filePath, $expectedFilePerms, $actualFilePerms) + \sprintf('File permissions for %s must be %s. Actual %s', $filePath, $expectedFilePerms, $actualFilePerms) ); } diff --git a/Tests/PathTest.php b/Tests/PathTest.php index 17c6142c3..285d55f0d 100644 --- a/Tests/PathTest.php +++ b/Tests/PathTest.php @@ -21,7 +21,7 @@ */ class PathTest extends TestCase { - protected $storedEnv = []; + protected array $storedEnv = []; protected function setUp(): void { diff --git a/composer.json b/composer.json index 5811ef590..c781e55b1 100644 --- a/composer.json +++ b/composer.json @@ -16,13 +16,12 @@ } ], "require": { - "php": ">=7.2.5", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8", - "symfony/polyfill-php80": "^1.16" + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4" + "symfony/process": "^6.4|^7.0" }, "autoload": { "psr-4": { "Symfony\\Component\\Filesystem\\": "" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5515fff1a..e7418f42c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - + + ./ - - ./Tests - ./vendor - - - + + + ./Tests + ./vendor + +