Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.74% covered (warning)
55.74%
68 / 122
44.44% covered (danger)
44.44%
8 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileSystem
55.74% covered (warning)
55.74%
68 / 122
44.44% covered (danger)
44.44%
8 / 18
220.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getFsRoot
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 makePathNormalizer
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getAdapter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setAdapter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeDirSeparator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 findAllFilesAbsolutePaths
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getAttributes
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 exists
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getRelativePath
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
6.60
 getProjectRelativePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isSymlinked
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 isSubDirOf
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 normalizePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prefixPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeAbsolute
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 isDirectoryEmpty
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 setLocalFsLocation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This class extends Flysystem's Filesystem class to add some additional functionality, particularly around
4 * symlinks which are not supported by Flysystem.
5 *
6 * @see https://github.com/thephpleague/flysystem/issues/599
7 */
8
9namespace BrianHenryIE\Strauss\Helpers\Flysystem;
10
11use Composer\Util\Platform;
12use Elazar\Flystream\StripProtocolPathNormalizer;
13use Exception;
14use League\Flysystem\Config;
15use League\Flysystem\FileAttributes;
16use League\Flysystem\FilesystemAdapter;
17use League\Flysystem\FilesystemException;
18use League\Flysystem\FilesystemReader;
19use League\Flysystem\Local\LocalFilesystemAdapter;
20use League\Flysystem\PathNormalizer;
21use League\Flysystem\PathPrefixer;
22use League\Flysystem\StorageAttributes;
23
24class FileSystem extends \League\Flysystem\Filesystem implements PathNormalizer, PathPrefixerInterface, FlysystemReaderBackCompatTraitInterface
25{
26    use FlysystemReaderBackCompatTrait;
27
28    /**
29     * @see \League\Flysystem\Filesystem::$pathNormalizer
30     */
31    protected PathNormalizer $pathNormalizer;
32
33    /**
34     * League does not have a PathPrefixer interface.
35     *
36     * @var \League\Flysystem\PathPrefixer|PathPrefixerInterface
37     */
38    protected $pathPrefixer;
39
40    /**
41     * For calculating absolute paths outside the flysystem.
42     *
43     * No trailing slash, except for root directories (e.g., '/' or 'C:/' or 'mem://').
44     */
45    protected string $localFsLocation;
46
47    /**
48     * For printing relative paths.
49     */
50    protected string $workingDir;
51
52    /**
53     * Private in parent class.
54     */
55    protected Config $config;
56
57    /**
58     * TODO: maybe restrict the constructor to only accept a LocalFilesystemAdapter.
59     *
60     * TODO: Check are any of these methods unused
61     *
62     * @param ReadOnlyFileSystemAdapter|SymlinkProtectFilesystemAdapter|LocalFilesystemAdapter|InMemoryFilesystemAdapter $adapter
63     * @param array{visibility?:string} $config
64     * @param \League\Flysystem\PathPrefixer|PathPrefixerInterface $pathPrefixer
65     * @param PathNormalizer|null $pathNormalizer
66     */
67    public function __construct(
68        FilesystemAdapter $adapter,
69        array $config = [],
70        ?PathNormalizer $pathNormalizer = null,
71        $pathPrefixer = null,
72        ?string $localFsLocation = null,
73        ?string $workingDir = null
74    ) {
75        $localFsLocation        = $localFsLocation ?? self::getFsRoot(Platform::getcwd());
76        $pathNormalizer         = $pathNormalizer ?? self::makePathNormalizer($localFsLocation);
77        $pathPrefixer           = $pathPrefixer ?? new PathPrefixer(
78            $localFsLocation,
79            DIRECTORY_SEPARATOR
80        );
81
82        parent::__construct($adapter, $config, $pathNormalizer);
83
84        $this->config = new Config($config);
85
86        // Parent is private.
87        $this->pathNormalizer  = $pathNormalizer;
88        $this->pathPrefixer    = $pathPrefixer;
89        $this->localFsLocation = $localFsLocation;
90        $this->workingDir      = $pathNormalizer->normalizePath($workingDir ?? $localFsLocation);
91    }
92
93    public static function getFsRoot(string $path): string
94    {
95        if (1 === preg_match('#^([a-zA-Z]+:[\\/]|\/)#', $path, $output_array)) {
96            return strtoupper($output_array[1]);
97        }
98        // Relative path.
99        return '';
100    }
101
102    public static function makePathNormalizer(string $workingDir): PathNormalizer
103    {
104        return new StripProtocolPathNormalizer(
105            [
106                'mem',
107            ],
108            new StripFsRootPathNormalizer(
109                [
110                    str_replace('\\', '/', FileSystem::getFsRoot($workingDir)),
111                    str_replace('/', '\\', FileSystem::getFsRoot($workingDir)),
112                    FileSystem::getFsRoot(Platform::getcwd()),
113                    FileSystem::normalizeDirSeparator(FileSystem::getFsRoot(Platform::getcwd())),
114                    'c:\\',
115                    'c:/',
116                ]
117            )
118        );
119    }
120
121    /**
122     * @see \League\Flysystem\Filesystem::$adapter
123     */
124    public function getAdapter(): FilesystemAdapter
125    {
126        $parentAdapterProperty = new \ReflectionProperty(\League\Flysystem\Filesystem::class, 'adapter');
127        PHP_VERSION_ID < 80100 && $parentAdapterProperty->setAccessible(true);
128        /** @var FilesystemAdapter */
129        return $parentAdapterProperty->getValue($this);
130    }
131
132    /**
133     * @see \League\Flysystem\Filesystem::$adapter
134     */
135    public function setAdapter(FilesystemAdapter $flysystemAdapter): void
136    {
137        $parentAdapterProperty = new \ReflectionProperty(\League\Flysystem\Filesystem::class, 'adapter');
138        PHP_VERSION_ID < 80100 && $parentAdapterProperty->setAccessible(true);
139        $parentAdapterProperty->setValue($this, $flysystemAdapter);
140    }
141
142    /**
143     * Normalize directory separators to forward slashes.
144     *
145     * PHP native functions (realpath, getcwd, dirname) return backslashes on Windows,
146     * but Flysystem always uses forward slashes. This method ensures consistency.
147     */
148    public static function normalizeDirSeparator(string $path, string $slashTo = '/'): string
149    {
150        $slashFrom = $slashTo === '/' ? '\\' : '/';
151
152        return str_replace($slashFrom, $slashTo, $path ?: '');
153    }
154
155    /**
156     * @param string[] $fileAndDirPaths
157     *
158     * @return string[]
159     * @throws FilesystemException
160     */
161    public function findAllFilesAbsolutePaths(array $fileAndDirPaths, bool $excludeDirectories = false): array
162    {
163        $files = [];
164
165        foreach ($fileAndDirPaths as $path) {
166            if (!$this->directoryExists($path)) {
167                $files[] = $path;
168                continue;
169            }
170
171            $directoryListing = $this->listContents(
172                $path,
173                FilesystemReader::LIST_DEEP
174            );
175
176            /** @var FileAttributes[] $fileAttributesArray */
177            $fileAttributesArray = $directoryListing->toArray();
178
179            $paths = array_map(
180                fn(StorageAttributes $attributes): string => $this->makeAbsolute($attributes->path()),
181                $fileAttributesArray
182            );
183
184            if ($excludeDirectories) {
185                $paths = array_filter($paths, fn($path) => !$this->directoryExists($path));
186            }
187
188            $files = array_merge($files, $paths);
189        }
190
191        return $files;
192    }
193
194    /**
195     * @throws FilesystemException
196     */
197    public function getAttributes(string $absolutePath): ?StorageAttributes
198    {
199        // TODO: check if `realpath()` is a bad idea here.
200        $fileDirectory = realpath(dirname($absolutePath)) ?: dirname($absolutePath);
201
202        $absolutePath = $this->normalizePath($absolutePath);
203
204        // Unsupported symbolic link encountered at location //home
205        // \League\Flysystem\SymbolicLinkEncountered
206        $dirList = $this->listContents($fileDirectory)->toArray();
207        foreach ($dirList as $file) { // TODO: use the generator.
208            if ($file->path() === $absolutePath) {
209                return $file;
210            }
211        }
212
213        return null;
214    }
215
216    /**
217     * TODO: rename to ::has()
218     * TODO: extract symlink handling to adapter.
219     * @throws FilesystemException
220     */
221    public function exists(string $location): bool
222    {
223        return $this->fileExists($location)
224               || $this->directoryExists($location)
225               || false !== realpath($this->prefixPath($this->normalizePath($location)));
226    }
227
228
229    /**
230     *
231     * /path/to/this/dir, /path/to/file.php => ../../file.php
232     * /path/to/here, /path/to/here/dir/file.php => dir/file.php
233     *
234     * @param string $fromAbsoluteDirectory
235     * @param string $toAbsolutePath
236     * @return string
237     */
238    public function getRelativePath(string $fromAbsoluteDirectory, string $toAbsolutePath): string
239    {
240        $fromAbsoluteDirectory = $this->normalizePath($fromAbsoluteDirectory);
241        $toAbsolutePath = $this->normalizePath($toAbsolutePath);
242
243        $fromDirectoryParts = array_filter(explode('/', $fromAbsoluteDirectory));
244        $toPathParts = array_filter(explode('/', $toAbsolutePath));
245        foreach ($fromDirectoryParts as $key => $part) {
246            if ($part === $toPathParts[$key]) {
247                unset($toPathParts[$key]);
248                unset($fromDirectoryParts[$key]);
249            } else {
250                break;
251            }
252            if (count($fromDirectoryParts) === 0 || count($toPathParts) === 0) {
253                break;
254            }
255        }
256
257        $relativePath =
258            str_repeat('../', count($fromDirectoryParts))
259            . implode('/', $toPathParts);
260
261        return rtrim($relativePath, '\\/');
262    }
263
264    public function getProjectRelativePath(string $absolutePath): string
265    {
266
267        // What will happen with strings that are not paths?!
268
269        return $this->getRelativePath(
270            $this->workingDir,
271            $absolutePath
272        );
273    }
274
275    /**
276     * Check does the filepath point to a file outside the working directory.
277     * Check is a file under a symlinked path.
278     *
279     * @throws FilesystemException
280     * @throws Exception
281     */
282    public function isSymlinked(string $path): bool
283    {
284        $normalizedPath = $this->normalizePath($path);
285
286        if (!$this->exists($normalizedPath)) {
287            throw new Exception('Path "' . $path . '" "' . $normalizedPath . '" does not exist.');
288        }
289
290        $osPath = $this->prefixPath($normalizedPath);
291
292        if (is_link($osPath)) {
293            return true;
294        }
295
296        if (realpath($osPath) !== $osPath) {
297            return true;
298        }
299
300        $workingDir = $this->normalizePath($this->localFsLocation);
301
302        return ! str_starts_with($normalizedPath, $workingDir);
303    }
304
305    /**
306     * Does the subDir path start with the dir path?
307     */
308    public function isSubDirOf(string $dir, string $subDir): bool
309    {
310        return str_starts_with(
311            $this->normalizePath($subDir),
312            $this->normalizePath($dir)
313        );
314    }
315
316    public function normalizePath(string $path): string
317    {
318        return $this->pathNormalizer->normalizePath($path);
319    }
320
321    public function prefixPath(string $path): string
322    {
323        /**
324         * @phpstan-ignore method.notFound
325         */
326        return $this->pathPrefixer->prefixPath($path);
327    }
328
329    /**
330     * Normalize a path and ensure it's absolute.
331     *
332     * Flysystem's normalizer strips leading slashes because paths are relative to the adapter root.
333     * When we need paths for external use (Composer, realpath, etc.), they must be absolute.
334     *
335     * - On Unix: prepends '/' if not present
336     * - On Windows: paths already have drive letters (e.g., 'C:/...') so no prefix needed
337     */
338    public function makeAbsolute(string $path): string
339    {
340        $fsRoot = self::getFsRoot($this->localFsLocation);
341
342        // If this is already prefixed with the drive(fs) root.
343        if (stripos($path, $fsRoot) === 0 || stripos($path, self::normalizeDirSeparator($fsRoot)) === 0) {
344            return $path;
345        }
346
347        $normalizedPath = $this->normalizePath($path);
348
349        if (strtolower(self::getFsRoot($this->localFsLocation)) === strtolower(self::getFsRoot($normalizedPath))) {
350            return $path;
351        }
352
353        $normalizedRoot = self::normalizeDirSeparator(self::getFsRoot($this->localFsLocation));
354
355        if (str_starts_with(strtoupper($normalizedPath), $normalizedRoot)) {
356            return self::normalizeDirSeparator($path, DIRECTORY_SEPARATOR);
357        }
358
359//        if ($this->getAdapter() instanceof InMemoryFilesystemAdapter || $this->getAdapter() instanceof ReadOnlyFileSystem) {
360        if (\Composer\Util\Filesystem::isStreamWrapperPath($this->localFsLocation)) {
361            return $this->localFsLocation . $path;
362        }
363
364        $prefixed = $this->prefixPath($this->normalizePath($path));
365
366//        if ($this->flysystemAdapter instanceof ReadOnlyFileSystem) {
367//            return str_replace(':/', '://', $prefixed);
368//        }
369
370        return self::normalizeDirSeparator($prefixed, DIRECTORY_SEPARATOR);
371    }
372
373    /**
374     * @throws FilesystemException
375     * @throws Exception
376     */
377    public function isDirectoryEmpty(string $dirPath): bool
378    {
379        if (!empty($this->listContents($dirPath)->toArray())) {
380            return false;
381        }
382
383        $fsPath = $this->prefixPath($this->normalizePath($dirPath) . DIRECTORY_SEPARATOR . '*');
384        $fsList = glob($fsPath);
385
386        if (false === $fsList) {
387            throw new Exception('glob() failed on ' . $fsPath);
388        }
389
390        return empty($fsList);
391    }
392
393    public function setLocalFsLocation(string $string): void
394    {
395        $this->localFsLocation = $string;
396    }
397}