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