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