Browse Source

Updated with latest updates from upstream, added tests

main v1.0.1
Dustin Wilson 5 months ago
parent
commit
43a5121123
  1. 3
      .gitignore
  2. 49
      .php-cs-fixer.php
  3. 9
      composer.json
  4. 3193
      composer.lock
  5. 127
      lib/Filesystem.php
  6. 3
      lib/Filesystem/FileNotFoundException.php
  7. 3
      lib/Filesystem/IOException.php
  8. 7
      lib/Filesystem/InvalidArgumentException.php
  9. 13
      lib/Filesystem/RuntimeException.php
  10. 64
      lib/Path.php
  11. 38
      test
  12. 18
      tests/bootstrap.php
  13. 41
      tests/cases/TestExceptions.php
  14. 1731
      tests/cases/TestFilesystem.php
  15. 1010
      tests/cases/TestPath.php
  16. 146
      tests/lib/FilesystemTestCase.php
  17. 38
      tests/lib/MockStream.php
  18. 22
      tests/phpunit.xml

3
.gitignore

@ -1,6 +1,9 @@
# Project-specific
/test*.*
/tests/.phpunit.cache
/build
.php-cs-fixer.cache
/symfony
# General
*.DS_Store

49
.php-cs-fixer.php

@ -0,0 +1,49 @@
<?php
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
'array_indentation' => true,
'array_syntax' => [ 'syntax' => 'short' ],
'blank_line_after_namespace' => false,
'blank_line_after_opening_tag' => false,
'blank_lines_before_namespace' => false,
'braces' => [
'allow_single_line_closure' => true,
'position_after_functions_and_oop_constructs' => 'same'
],
'braces_position' => [
'classes_opening_brace' => 'same_line',
'functions_opening_brace' => 'same_line'
],
'class_attributes_separation' => [ 'elements' => [ 'method' => 'one' ] ],
'combine_consecutive_unsets' => true,
'concat_space' => ['spacing' => 'one'],
'declare_equal_normalize' => true,
'function_typehint_space' => true,
'general_phpdoc_annotation_remove' => [],
'include' => true,
'lowercase_cast' => true,
'multiline_whitespace_before_semicolons' => false,
'no_blank_lines_before_namespace' => true,
'no_extra_blank_lines' => [
'tokens' => [
'curly_brace_block',
'extra',
'throw',
'use'
]
],
'no_multiline_whitespace_around_double_arrow' => true,
'no_spaces_around_offset' => true,
'no_whitespace_before_comma_in_array' => true,
'no_whitespace_in_blank_line' => true,
'object_operator_without_whitespace' => true,
'single_quote' => true,
'space_after_semicolon' => true,
'ternary_operator_spaces' => true,
'trim_array_spaces' => true,
'unary_operator_spaces' => true,
'whitespace_after_comma_in_array' => true,
])
->setLineEnding("\n")
;

9
composer.json

@ -8,6 +8,11 @@
"MensBeam\\": "lib/"
}
},
"autoload-dev": {
"psr-4": {
"MensBeam\\Filesystem\\Test\\": "tests/lib/"
}
},
"authors": [
{
"name": "Dustin Wilson",
@ -20,7 +25,9 @@
"symfony/polyfill-mbstring": ">=1.8"
},
"require-dev": {
"symfony/filesystem": ">=6.2"
"friendsofphp/php-cs-fixer": "^3.38",
"symfony/filesystem": "*",
"phpunit/phpunit": "^10.4"
},
"suggest": {
"ext-ctype": "For better performance",

3193
composer.lock

File diff suppressed because it is too large

127
lib/Filesystem.php

@ -1,10 +1,10 @@
<?php
/**
* @license MIT
* @license MIT
* Copyright 2023 Dustin Wilson, J. King, et al.
* Original copyright 2023 Fabien Potencier
* See LICENSE and AUTHORS files for details
*/
*/
declare(strict_types=1);
namespace MensBeam;
@ -14,7 +14,6 @@ use MensBeam\Filesystem\{
IOException
};
/**
* Provides basic utility to manipulate the file system.
*
@ -30,6 +29,8 @@ class Filesystem {
* If the target file is newer, it is overwritten only when the
* $overwriteNewerFiles option is set to true.
*
* @return void
*
* @throws FileNotFoundException When originFile doesn't exist
* @throws IOException When copy fails
*/
@ -49,12 +50,12 @@ class Filesystem {
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);
@ -80,6 +81,8 @@ class Filesystem {
/**
* Creates a directory recursively.
*
* @return void
*
* @throws IOException On any directory creation failure
*/
public static function mkdir(string|iterable $dirs, int $mode = 0777) {
@ -89,7 +92,7 @@ class Filesystem {
}
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);
}
}
}
@ -119,12 +122,14 @@ class Filesystem {
* @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
*
* @return void
*
* @throws IOException When touch fails
*/
public static function touch(string|iterable $files, int $time = null, int $atime = null) {
foreach (self::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);
}
}
}
@ -132,6 +137,8 @@ class Filesystem {
/**
* Removes files or directories.
*
* @return void
*
* @throws IOException When removal fails
*/
public static function remove(string|iterable $files) {
@ -147,14 +154,15 @@ class Filesystem {
private static function doRemove(array $files, bool $isRecursive): void {
$files = array_reverse($files);
foreach ($files as $file) {
$file = (string)$file;
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) {
$tmpName = \dirname(realpath($file)).'/.'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-.'));
$tmpName = \dirname(realpath($file)) . '/.' . strrev(strtr(base64_encode(random_bytes(2)), '/=', '-_'));
if (file_exists($tmpName)) {
try {
@ -181,10 +189,10 @@ class Filesystem {
$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) && (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);
}
}
}
@ -196,12 +204,15 @@ class Filesystem {
* @param int $umask The mode mask (octal)
* @param bool $recursive Whether change the mod recursively or not
*
* @return void
*
* @throws IOException When the change fails
*/
public static function chmod(string|iterable $files, int $mode, int $umask = 0000, bool $recursive = false) {
foreach (self::toIterable($files) as $file) {
if (\is_int($mode) && !self::box('chmod', $file, $mode & ~$umask)) {
throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file);
$file = (string)$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)) {
self::chmod(new \FilesystemIterator($file), $mode, $umask, true);
@ -215,20 +226,23 @@ class Filesystem {
* @param string|int $user A user name or number
* @param bool $recursive Whether change the owner recursively or not
*
* @return void
*
* @throws IOException When the change fails
*/
public static function chown(string|iterable $files, string|int $user, bool $recursive = false) {
foreach (self::toIterable($files) as $file) {
$file = (string)$file;
if ($recursive && is_dir($file) && !is_link($file)) {
self::chown(new \FilesystemIterator($file), $user, true);
}
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);
}
}
}
@ -240,20 +254,23 @@ class Filesystem {
* @param string|int $group A group name or number
* @param bool $recursive Whether change the group recursively or not
*
* @return void
*
* @throws IOException When the change fails
*/
public static function chgrp(string|iterable $files, string|int $group, bool $recursive = false) {
foreach (self::toIterable($files) as $file) {
$file = (string)$file;
if ($recursive && is_dir($file) && !is_link($file)) {
self::chgrp(new \FilesystemIterator($file), $group, true);
}
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);
}
}
}
@ -262,6 +279,8 @@ class Filesystem {
/**
* Renames a file or a directory.
*
* @return void
*
* @throws IOException When target file or directory already exists
* @throws IOException When origin cannot be renamed
*/
@ -279,7 +298,7 @@ class Filesystem {
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);
}
}
@ -301,6 +320,8 @@ class Filesystem {
/**
* Creates a symbolic link or copy a directory.
*
* @return void
*
* @throws IOException When symlink fails
*/
public static function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false) {
@ -336,6 +357,8 @@ class Filesystem {
*
* @param string|string[] $targetFiles The target file(s)
*
* @return void
*
* @throws FileNotFoundException When original file is missing or not a file
* @throws IOException When link fails, including if link already exists
*/
@ -367,13 +390,13 @@ class Filesystem {
/**
* @param string $linkType Name of the link type, typically 'symbolic' or 'hard'
*/
private static function linkException(string $origin, string $target, string $linkType) {
private static 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('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);
}
/**
@ -421,11 +444,9 @@ class Filesystem {
$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 = [];
@ -449,7 +470,7 @@ class Filesystem {
if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) {
// End path is on another drive, so no relative path exists
return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : '');
return $endDriveLetter . ':/' . ($endPathArr ? implode('/', $endPathArr) . '/' : '');
}
// Find for which directory the common path stops
@ -471,7 +492,7 @@ class Filesystem {
$endPathRemainder = implode('/', \array_slice($endPathArr, $index));
// Construct $endPath from traversing to the common path, then to the remaining $endPath
$relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : '');
$relativePath = $traverser . ('' !== $endPathRemainder ? $endPathRemainder . '/' : '');
return '' === $relativePath ? './' : $relativePath;
}
@ -491,6 +512,8 @@ class Filesystem {
* - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false)
* - $options['delete'] Whether to delete files that are not in the source directory (defaults to false)
*
* @return void
*
* @throws IOException When file type is unknown
*/
public static function mirror(string $originDir, string $targetDir, \Traversable $iterator = null, array $options = []) {
@ -511,9 +534,9 @@ class Filesystem {
}
$targetDirLen = \strlen($targetDir);
foreach ($deleteIterator as $file) {
$origin = $originDir.substr($file->getPathname(), $targetDirLen);
$origin = $originDir . substr($file->getPathname(), $targetDirLen);
if (!self::exists($origin)) {
self::remove($file);
self::remove((string)$file);
}
}
}
@ -533,15 +556,15 @@ class Filesystem {
continue;
}
$target = $targetDir.substr($file->getPathname(), $originDirLen);
$target = $targetDir . substr($file->getPathname(), $originDirLen);
$filesCreatedWhileMirroring[$target] = true;
if (!$copyOnWindows && is_link($file)) {
if (!$copyOnWindows && is_link((string)$file)) {
self::symlink($file->getLinkTarget(), $target);
} elseif (is_dir($file)) {
} elseif (is_dir((string)$file)) {
self::mkdir($target);
} elseif (is_file($file)) {
self::copy($file, $target, $options['override'] ?? false);
} elseif (is_file((string)$file)) {
self::copy((string)$file, $target, $options['override'] ?? false);
} else {
throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file);
}
@ -552,8 +575,10 @@ class Filesystem {
* Returns whether the file path is an absolute path.
*/
public static function isAbsolutePath(string $file): bool {
return '' !== $file && (strspn($file, '/\\', 0, 1)
|| (\strlen($file) > 3 && ctype_alpha($file[0])
return '' !== $file && (
strspn($file, '/\\', 0, 1)
|| (
\strlen($file) > 3 && ctype_alpha($file[0])
&& ':' === $file[1]
&& strspn($file, '/\\', 2, 1)
)
@ -578,19 +603,19 @@ class Filesystem {
// If tempnam failed or no scheme return the filename otherwise prepend the scheme
if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) {
if (null !== $scheme && 'gs' !== $scheme) {
return $scheme.'://'.$tmpFile;
return $scheme . '://' . $tmpFile;
}
return $tmpFile;
}
throw new IOException('A temporary file could not be created: '.self::$lastError);
throw new IOException('A temporary file could not be created: ' . self::$lastError);
}
// 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((string)mt_rand(), true).$suffix;
$tmpFile = $dir . '/' . $prefix . uniqid((string)mt_rand(), true) . $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
@ -604,7 +629,7 @@ class Filesystem {
return $tmpFile;
}
throw new IOException('A temporary file could not be created: '.self::$lastError);
throw new IOException('A temporary file could not be created: ' . self::$lastError);
}
/**
@ -612,6 +637,8 @@ class Filesystem {
*
* @param string|resource $content The data to write into the file
*
* @return void
*
* @throws IOException if the file cannot be written to
*/
public static function dumpFile(string $filename, $content) {
@ -621,6 +648,12 @@ class Filesystem {
$dir = \dirname($filename);
if (is_link($filename) && $linkTarget = self::readlink($filename)) {
self::dumpFile(Path::makeAbsolute($linkTarget, $dir), $content);
return;
}
if (!is_dir($dir)) {
self::mkdir($dir);
}
@ -631,7 +664,7 @@ class Filesystem {
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, file_exists($filename) ? fileperms($filename) : 0666 & ~umask());
@ -650,6 +683,8 @@ class Filesystem {
* @param string|resource $content The content to append
* @param bool $lock Whether the file should be locked when writing to it
*
* @return void
*
* @throws IOException If the file is not writable
*/
public static function appendToFile(string $filename, $content/* , bool $lock = false */) {
@ -666,7 +701,7 @@ class Filesystem {
$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);
}
}
@ -693,7 +728,7 @@ class Filesystem {
self::assertFunctionExists($func);
self::$lastError = null;
set_error_handler(__CLASS__.'::handleError');
set_error_handler(__CLASS__ . '::handleError');
try {
return $func(...$args);
} finally {
@ -704,10 +739,10 @@ class Filesystem {
/**
* @internal
*/
public static function handleError(int $type, string $msg) {
public static function handleError(int $type, string $msg): void {
self::$lastError = $msg;
}
private function __construct() {}
private function __construct() {
}
}

3
lib/Filesystem/FileNotFoundException.php

@ -9,7 +9,6 @@
declare(strict_types=1);
namespace MensBeam\Filesystem;
/**
* Exception class thrown when a file couldn't be found.
*
@ -28,4 +27,4 @@ class FileNotFoundException extends IOException {
parent::__construct($message, $code, $previous, $path);
}
}
}

3
lib/Filesystem/IOException.php

@ -9,7 +9,6 @@
declare(strict_types=1);
namespace MensBeam\Filesystem;
/**
* Exception class thrown when a filesystem operation failure happens.
*
@ -29,4 +28,4 @@ class IOException extends \RuntimeException {
public function getPath(): ?string {
return $this->path;
}
}
}

7
lib/Filesystem/InvalidArgumentException.php

@ -9,8 +9,5 @@
declare(strict_types=1);
namespace MensBeam\Filesystem;
/**
* @author Christian Flothmann <christian.flothmann@sensiolabs.de>
*/
class InvalidArgumentException extends \InvalidArgumentException {}
class InvalidArgumentException extends \InvalidArgumentException {
}

13
lib/Filesystem/RuntimeException.php

@ -0,0 +1,13 @@
<?php
/**
* @license MIT
* Copyright 2023 Dustin Wilson, J. King, et al.
* Original copyright 2023 Fabien Potencier
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Filesystem;
class RuntimeException extends \RuntimeException {
}

64
lib/Path.php

@ -1,14 +1,17 @@
<?php
/**
* @license MIT
* @license MIT
* Copyright 2023 Dustin Wilson, J. King, et al.
* Original copyright 2023 Fabien Potencier
* See LICENSE and AUTHORS files for details
*/
*/
declare(strict_types=1);
namespace MensBeam;
use MensBeam\Filesystem\{
InvalidArgumentException,
RuntimeException
};
/**
* Contains utility methods for handling path strings.
@ -25,12 +28,12 @@ class Path {
/**
* The number of buffer entries that triggers a cleanup operation.
*/
protected const CLEANUP_THRESHOLD = 1250;
private const CLEANUP_THRESHOLD = 1250;
/**
* The buffer size after the cleanup operation.
*/
protected const CLEANUP_SIZE = 1000;
private const CLEANUP_SIZE = 1000;
/**
* Buffers input/output of {@link canonicalize()}.
@ -75,7 +78,7 @@ class Path {
// Replace "~" with user's home directory.
if ('~' === $path[0]) {
$path = self::getHomeDirectory().substr($path, 1);
$path = self::getHomeDirectory() . substr($path, 1);
}
$path = self::normalize($path);
@ -85,7 +88,7 @@ class Path {
$canonicalParts = self::findCanonicalParts($root, $pathWithoutRoot);
// Add the root directory again
self::$buffer[$path] = $canonicalPath = $root.implode('/', $canonicalParts);
self::$buffer[$path] = $canonicalPath = $root . implode('/', $canonicalParts);
++self::$bufferSize;
// Clean up regularly to prevent memory leaks
@ -156,15 +159,15 @@ class Path {
// Directory equals root directory "/"
if (0 === $dirSeparatorPosition) {
return $scheme.'/';
return $scheme . '/';
}
// Directory equals Windows root "C:/"
if (2 === $dirSeparatorPosition && ctype_alpha($path[0]) && ':' === $path[1]) {
return $scheme.substr($path, 0, 3);
return $scheme . substr($path, 0, 3);
}
return $scheme.substr($path, 0, $dirSeparatorPosition);
return $scheme . substr($path, 0, $dirSeparatorPosition);
}
/**
@ -179,7 +182,7 @@ class Path {
*
* The result is a canonical path.
*
* @throws \RuntimeException If your operating system or environment isn't supported
* @throws RuntimeException If your operating system or environment isn't supported
*/
public static function getHomeDirectory(): string {
// For UNIX support
@ -189,10 +192,10 @@ class Path {
// For >= Windows8 support
if (getenv('HOMEDRIVE') && getenv('HOMEPATH')) {
return self::canonicalize(getenv('HOMEDRIVE').getenv('HOMEPATH'));
return self::canonicalize(getenv('HOMEDRIVE') . getenv('HOMEPATH'));
}
throw new \RuntimeException("Cannot find the home directory path: Your environment or operating system isn't supported.");
throw new RuntimeException("Cannot find the home directory path: Your environment or operating system isn't supported.");
}
/**
@ -220,7 +223,7 @@ class Path {
// UNIX root "/" or "\" (Windows style)
if ('/' === $firstCharacter || '\\' === $firstCharacter) {
return $scheme.'/';
return $scheme . '/';
}
$length = \strlen($path);
@ -229,12 +232,12 @@ class Path {
if ($length > 1 && ':' === $path[1] && ctype_alpha($firstCharacter)) {
// Special case: "C:"
if (2 === $length) {
return $scheme.$path.'/';
return $scheme . $path . '/';
}
// Normal case: "C:/ or "C:\"
if ('/' === $path[2] || '\\' === $path[2]) {
return $scheme.$firstCharacter.$path[1].'/';
return $scheme . $firstCharacter . $path[1] . '/';
}
}
@ -341,10 +344,10 @@ class Path {
// No actual extension in path
if (empty($actualExtension)) {
return $path.('.' === substr($path, -1) ? '' : '.').$extension;
return $path . ('.' === substr($path, -1) ? '' : '.') . $extension;
}
return substr($path, 0, -\strlen($actualExtension)).$extension;
return substr($path, 0, -\strlen($actualExtension)) . $extension;
}
public static function isAbsolute(string $path): bool {
@ -417,17 +420,17 @@ class Path {
*
* @param string $basePath an absolute base path
*
* @throws \InvalidArgumentException if the base path is not absolute or if
* @throws InvalidArgumentException if the base path is not absolute or if
* the given path is an absolute path with
* a different root than the base path
*/
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)) {
@ -441,7 +444,7 @@ class Path {
$scheme = '';
}
return $scheme.self::canonicalize(rtrim($basePath, '/\\').'/'.$path);
return $scheme . self::canonicalize(rtrim($basePath, '/\\') . '/' . $path);
}
/**
@ -516,12 +519,12 @@ class Path {
// 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) {
@ -548,7 +551,7 @@ class Path {
$dotDotPrefix .= '../';
}
return rtrim($dotDotPrefix.implode('/', $parts), '/');
return rtrim($dotDotPrefix . implode('/', $parts), '/');
}
/**
@ -618,7 +621,7 @@ class Path {
// Prevent false positives for common prefixes
// see isBasePath()
if (str_starts_with($path.'/', $basePath.'/')) {
if (str_starts_with($path . '/', $basePath . '/')) {
// next path
continue 2;
}
@ -627,7 +630,7 @@ class Path {
}
}
return $bpRoot.$basePath;
return $bpRoot . $basePath;
}
/**
@ -695,7 +698,7 @@ class Path {
// 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 str_starts_with($ofPath.'/', rtrim($basePath, '/').'/');
return str_starts_with($ofPath . '/', rtrim($basePath, '/') . '/');
}
/**
@ -768,7 +771,7 @@ class Path {
} elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) {
if (2 === $length) {
// Windows special case: "C:"
$root .= $path.'/';
$root .= $path . '/';
$path = '';
} elseif ('/' === $path[2]) {
// Windows normal case: "C:/"..
@ -788,5 +791,6 @@ class Path {
return strtolower($string);
}
private function __construct() {}
private function __construct() {
}
}

38
test

@ -0,0 +1,38 @@
#!/usr/bin/env php
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
$dir = ini_get('extension_dir');
$php = escapeshellarg(\PHP_BINARY);
$code = escapeshellarg(__DIR__ . '/lib');
array_shift($argv);
foreach ($argv as $k => $v) {
if (in_array($v, ['--coverage', '--coverage-html'])) {
$argv[$k] = '--coverage-html tests/coverage';
}
}
$cmd = [
$php,
'-d opcache.enable_cli=0',
];
if (!extension_loaded('xdebug')) {
$cmd[] = '-d zend_extension=xdebug.so';
}
$cmd = implode(' ', [
...$cmd,
'-d xdebug.mode=coverage,develop,trace',
escapeshellarg(__DIR__ . '/vendor/bin/phpunit'),
'--configuration tests/phpunit.xml',
...$argv,
'--display-deprecations'
]);
passthru($cmd);

18
tests/bootstrap.php

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MensBeam\Catcher\Test;
ini_set('memory_limit', '2G');
ini_set('zend.assertions', '1');
ini_set('assert.exception', 'true');
error_reporting(\E_ALL);
define('CWD', dirname(__DIR__));
require_once CWD . '/vendor/autoload.php';
if (function_exists('xdebug_set_filter')) {
if (defined('XDEBUG_PATH_INCLUDE')) {
xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, \XDEBUG_PATH_INCLUDE, [CWD . '/lib/']);
} else {
xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, \XDEBUG_PATH_WHITELIST, [CWD . '/lib/']);
}
}

41
tests/cases/TestExceptions.php

@ -0,0 +1,41 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Filesystem\Test;
use PHPUnit\Framework\TestCase;
use MensBeam\Filesystem\FileNotFoundException;
use MensBeam\Filesystem\IOException;
/**
* @covers \MensBeam\Filesystem\FileNotFoundException
* @covers \MensBeam\Filesystem\InvalidArgumentException
* @covers \MensBeam\Filesystem\IOException
*/
class TestExceptions extends TestCase {
public function testGetPath() {
$e = new IOException('', 0, null, '/foo');
$this->assertEquals('/foo', $e->getPath(), 'The pass should be returned.');
}
public function testGeneratedMessage() {
$e = new FileNotFoundException(null, 0, null, '/foo');
$this->assertEquals('/foo', $e->getPath());
$this->assertEquals('File "/foo" could not be found.', $e->getMessage(), 'A message should be generated.');
}
public function testGeneratedMessageWithoutPath() {
$e = new FileNotFoundException();
$this->assertEquals('File could not be found.', $e->getMessage(), 'A message should be generated.');
}
public function testCustomMessage() {
$e = new FileNotFoundException('bar', 0, null, '/foo');
$this->assertEquals('bar', $e->getMessage(), 'A custom message should be possible still.');
}
}

1731
tests/cases/TestFilesystem.php

File diff suppressed because it is too large

1010
tests/cases/TestPath.php

File diff suppressed because it is too large

146
tests/lib/FilesystemTestCase.php

@ -0,0 +1,146 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Filesystem\Test;
use MensBeam\Filesystem as Fs,
PHPUnit\Framework\TestCase;
class FilesystemTestCase extends TestCase {
protected array $longPathNamesWindows = [];
protected string $workspace;
private int $umask;
private static ?bool $linkOnWindows = null;
private static ?bool $symlinkOnWindows = null;
public static function setUpBeforeClass(): void {
if ('\\' === \DIRECTORY_SEPARATOR) {
self::$linkOnWindows = true;
$originFile = tempnam(sys_get_temp_dir(), 'li');
$targetFile = tempnam(sys_get_temp_dir(), 'li');
if (true !== @link($originFile, $targetFile)) {
$report = error_get_last();
if (\is_array($report) && str_contains($report['message'], 'error code(1314)')) {
self::$linkOnWindows = false;
}
} else {
@unlink($targetFile);
}
self::$symlinkOnWindows = true;
$originDir = tempnam(sys_get_temp_dir(), 'sl');
$targetDir = tempnam(sys_get_temp_dir(), 'sl');
if (true !== @symlink($originDir, $targetDir)) {
$report = error_get_last();
if (\is_array($report) && str_contains($report['message'], 'error code(1314)')) {
self::$symlinkOnWindows = false;
}
} else {
@unlink($targetDir);
}
}
}
protected function setUp(): void {
$this->umask = umask(0);
$this->workspace = sys_get_temp_dir() . '/' . microtime(true) . '.' . mt_rand();
mkdir($this->workspace, 0777, true);
$this->workspace = realpath($this->workspace);
}
protected function tearDown(): void {
if (!empty($this->longPathNamesWindows)) {
foreach ($this->longPathNamesWindows as $path) {
exec('DEL ' . $path);
}
$this->longPathNamesWindows = [];
}
Fs::remove($this->workspace);
umask($this->umask);
}
/**
* @param int $expectedFilePerms Expected file permissions as three digits (i.e. 755)
* @param string $filePath
*/
protected function assertFilePermissions($expectedFilePerms, $filePath) {
$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)
);
}
protected function getFileOwnerId($filepath) {
$this->markAsSkippedIfPosixIsMissing();
$infos = stat($filepath);
return $infos['uid'];
}
protected function getFileOwner($filepath) {
$this->markAsSkippedIfPosixIsMissing();
return ($datas = posix_getpwuid($this->getFileOwnerId($filepath))) ? $datas['name'] : null;
}
protected function getFileGroupId($filepath) {
$this->markAsSkippedIfPosixIsMissing();
$infos = stat($filepath);
return $infos['gid'];
}
protected function getFileGroup($filepath) {
$this->markAsSkippedIfPosixIsMissing();
if ($datas = posix_getgrgid($this->getFileGroupId($filepath))) {
return $datas['name'];
}
$this->markTestSkipped('Unable to retrieve file group name');
}
protected function markAsSkippedIfLinkIsMissing() {
if (!\function_exists('link')) {
$this->markTestSkipped('link is not supported');
}
if ('\\' === \DIRECTORY_SEPARATOR && false === self::$linkOnWindows) {
$this->markTestSkipped('link requires "Create hard links" privilege on windows');
}
}
protected function markAsSkippedIfSymlinkIsMissing($relative = false) {
if ('\\' === \DIRECTORY_SEPARATOR && false === self::$symlinkOnWindows) {
$this->markTestSkipped('symlink requires "Create symbolic links" privilege on Windows');
}
// https://bugs.php.net/69473
if ($relative && '\\' === \DIRECTORY_SEPARATOR && 1 === \PHP_ZTS) {
$this->markTestSkipped('symlink does not support relative paths on thread safe Windows PHP versions');
}
}
protected function markAsSkippedIfChmodIsMissing() {
if ('\\' === \DIRECTORY_SEPARATOR) {
$this->markTestSkipped('chmod is not supported on Windows');
}
}
protected function markAsSkippedIfPosixIsMissing() {
if (!\function_exists('posix_isatty')) {
$this->markTestSkipped('Function posix_isatty is required.');
}
}
}

38
tests/lib/MockStream.php

@ -0,0 +1,38 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Filesystem\Test;
/**
* Mock stream class to be used with stream_wrapper_register.
* stream_wrapper_register('mock', '\MensBeam\Filesystem\Tests\MockStream').
*/
class MockStream {
public $context;
/**
* Opens file or URL.
*
* @param string $path Specifies the URL that was passed to the original function
* @param string $mode The mode used to open the file, as detailed for fopen()
* @param int $options Holds additional flags set by the streams API
* @param string|null $opened_path If the path is opened successfully, and STREAM_USE_PATH is set in options,
* opened_path should be set to the full path of the file/resource that was actually opened
*/
public function stream_open(string $path, string $mode, int $options, string &$opened_path = null): bool {
return true;
}
/**
* @param string $path The file path or URL to stat
* @param int $flags Holds additional flags set by the streams API
*/
public function url_stat(string $path, int $flags): array {
return [];
}
}

22
tests/phpunit.xml

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true"
bootstrap="bootstrap.php"
cacheDirectory=".phpunit.cache"
colors="true"
executionOrder="defects"
requireCoverageMetadata="true"
>
<testsuites>
<testsuite name="Main">
<directory prefix="Test" suffix=".php">./cases</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">../lib</directory>
</include>
</source>
</phpunit>
Loading…
Cancel
Save