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