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