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