Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.84% covered (danger)
5.84%
8 / 137
4.17% covered (danger)
4.17%
1 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReadOnlyFileSystemAdapter
5.84% covered (danger)
5.84%
8 / 137
4.17% covered (danger)
4.17%
1 / 24
3271.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getAdapter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fileExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 write
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 writeStream
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 rewindStream
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 read
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 readStream
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 delete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 deleteDirectory
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createDirectory
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 listContents
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 move
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 copy
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getAttributes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 lastModified
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 fileSize
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 mimeType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setVisibility
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 visibility
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 directoryExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 directoryExistsIn
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getNormalizer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalizePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * When running with `--dry-run` the filesystem should be read-only.
4 *
5 * This should work with read operations working as normal but write operations should be
6 * cached so they appear to have been successful but are not actually written to disk.
7 */
8
9namespace BrianHenryIE\Strauss\Helpers\Flysystem;
10
11use BadMethodCallException;
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\FilesystemReader;
19use League\Flysystem\Local\LocalFilesystemAdapter;
20use League\Flysystem\PathNormalizer;
21use League\Flysystem\StorageAttributes;
22use League\Flysystem\UnableToReadFile;
23use League\Flysystem\UnableToRetrieveMetadata;
24use League\Flysystem\Visibility;
25use League\Flysystem\WhitespacePathNormalizer;
26use Traversable;
27
28// TODO: When a directory is deleted, all the files in that directory should be marked deleted?
29// OR each parent directory of a file should be checked it exists before the file is read?
30
31class ReadOnlyFileSystemAdapter implements FilesystemAdapter, FlysystemAdapterBackCompatTraitInterface
32{
33    use FlysystemAdapterBackCompatTrait;
34
35    protected FilesystemAdapter $delegateFilesystemAdapter;
36    protected ModifiedFilesInMemoryFilesystemAdapter $inMemoryFiles;
37    protected DeletedFilesInMemoryFilesystemAdapter $deletedFiles;
38
39    protected PathNormalizer $pathNormalizer;
40
41    public function __construct(
42        FilesystemAdapter $delegateFilesystem,
43        ?PathNormalizer $pathNormalizer = null
44    ) {
45        $this->delegateFilesystemAdapter = $delegateFilesystem;
46
47        $this->inMemoryFiles = new ModifiedFilesInMemoryFilesystemAdapter();
48        $this->deletedFiles = new DeletedFilesInMemoryFilesystemAdapter();
49
50        $this->pathNormalizer = $pathNormalizer ?? new WhitespacePathNormalizer();
51    }
52
53    public function getAdapter(): FilesystemAdapter
54    {
55        return $this->delegateFilesystemAdapter;
56    }
57
58    public function fileExists(string $location): bool
59    {
60        $location = $this->pathNormalizer->normalizePath($location);
61
62        if ($this->deletedFiles->fileExists($location)) {
63            return false;
64        }
65        return $this->inMemoryFiles->fileExists($location)
66               || $this->delegateFilesystemAdapter->fileExists($location);
67    }
68
69    /**
70     * @param Config|array{visibility?:string} $config
71     * @throws FilesystemException
72     */
73    public function write(string $location, string $contents, $config = []): void
74    {
75        $location = $this->pathNormalizer->normalizePath($location);
76
77        $config = $config instanceof Config ? $config : new Config($config);
78        $this->inMemoryFiles->write($location, $contents, $config);
79
80        if ($this->deletedFiles->fileExists($location)) {
81            $this->deletedFiles->delete($location);
82        }
83    }
84
85    /**
86     * @see FilesystemAdapter::writeStream()
87     * @param resource $contents
88     * @param Config|array{visibility?:string} $config
89     * @throws FilesystemException
90     */
91    public function writeStream(string $path, $contents, $config): void
92    {
93        $path = $this->pathNormalizer->normalizePath($path);
94
95        $config = $config instanceof Config ? $config : new Config($config);
96        $this->rewindStream($contents);
97        $this->inMemoryFiles->writeStream($path, $contents, $config);
98
99        if ($this->deletedFiles->fileExists($path)) {
100            $this->deletedFiles->delete($path);
101        }
102    }
103    /**
104     * @param resource $resource
105     */
106    private function rewindStream($resource): void
107    {
108        if (ftell($resource) !== 0 && stream_get_meta_data($resource)['seekable']) {
109            rewind($resource);
110        }
111    }
112
113    public function read(string $path): string
114    {
115        $path = $this->pathNormalizer->normalizePath($path);
116
117        if ($this->deletedFiles->fileExists($path)) {
118            throw UnableToReadFile::fromLocation($path);
119        }
120        if ($this->inMemoryFiles->fileExists($path)) {
121            return $this->inMemoryFiles->read($path);
122        }
123        return $this->delegateFilesystemAdapter->read($path);
124    }
125
126    public function readStream(string $path)
127    {
128        $path = $this->pathNormalizer->normalizePath($path);
129
130        if ($this->deletedFiles->fileExists($path)) {
131            throw UnableToReadFile::fromLocation($path);
132        }
133        if ($this->inMemoryFiles->fileExists($path)) {
134            return $this->inMemoryFiles->readStream($path);
135        }
136        return $this->delegateFilesystemAdapter->readStream($path);
137    }
138
139    public function delete(string $path): void
140    {
141        $path = $this->pathNormalizer->normalizePath($path);
142
143        if ($this->fileExists($path)) {
144            $file = $this->read($path);
145            $this->deletedFiles->write($path, $file, new Config([]));
146        }
147
148        if ($this->inMemoryFiles->fileExists($path)) {
149            $this->inMemoryFiles->delete($path);
150        }
151    }
152
153    public function deleteDirectory(string $path): void
154    {
155        $path = $this->pathNormalizer->normalizePath($path);
156
157        $this->deletedFiles->createDirectory($path, new Config([]));
158        $this->inMemoryFiles->deleteDirectory($path);
159    }
160
161    /**
162     * @param Config|array{visibility?:string} $config
163     * @throws FilesystemException
164     */
165    public function createDirectory(string $path, $config = []): void
166    {
167        $path = $this->pathNormalizer->normalizePath($path);
168
169        $this->inMemoryFiles->createDirectory(
170            $path,
171            $config instanceof Config ? $config : new Config($config)
172        );
173
174        $this->deletedFiles->deleteDirectory($path);
175    }
176
177    public function listContents(string $path, bool $deep): iterable
178    {
179        $path = $this->pathNormalizer->normalizePath($path);
180
181        $deletedFilesGenerator = $this->deletedFiles->listContents($path, $deep);
182        $deletedFilesArray = $deletedFilesGenerator instanceof Traversable
183            ? iterator_to_array($deletedFilesGenerator, false)
184            : (array) $deletedFilesGenerator;
185        $deletedFilePaths = array_map(fn($file) => $file->path(), $deletedFilesArray);
186
187        $inMemoryFilesGenerator = $this->inMemoryFiles->listContents($path, $deep);
188        $inMemoryFilesArray = $inMemoryFilesGenerator instanceof Traversable
189            ? iterator_to_array($inMemoryFilesGenerator, false)
190            : (array) $inMemoryFilesGenerator;
191
192        // Remove deleted files from the modified files filesystem array
193        $inMemoryFilesArray = array_filter($inMemoryFilesArray, fn($file) => !in_array($file->path(), $deletedFilePaths));
194
195        $inMemoryFilePaths = (array) array_map(fn($file) => $file->path(), $inMemoryFilesArray);
196
197        $parentFilesystemGenerator = $this->delegateFilesystemAdapter->listContents($path, $deep);
198        /** @var FileAttributes[] $parentFilesystemArray */
199        $parentFilesystemArray = $parentFilesystemGenerator instanceof Traversable
200            ? iterator_to_array($parentFilesystemGenerator, false)
201            : (array) $parentFilesystemGenerator;
202//      $parentFilesystemPaths = (array) array_map(fn($file) => $file->path(), $parentFilesystemArray);
203
204        // Remove modified files from the parent filesystem array
205        $parentFilesystemArray = array_filter($parentFilesystemArray, fn($file) => !in_array($file->path(), $inMemoryFilePaths));
206        // Remove deleted files from the parent filesystem array
207        $parentFilesystemArray = array_filter($parentFilesystemArray, fn($file) => !in_array($file->path(), $deletedFilePaths));
208
209        $good = array_merge($parentFilesystemArray, $inMemoryFilesArray);
210
211        return new DirectoryListing($good);
212    }
213
214    /**
215     * @param Config|array{visibility?:string} $config
216     */
217    public function move(string $source, string $destination, $config): void
218    {
219        throw new BadMethodCallException('Not yet implemented');
220    }
221
222    /**
223     * @see FilesystemAdapter::copy()
224     *
225     * @param Config|array{visibility?:string}|null $config
226     * @throws FilesystemException
227     * @throws Exception
228     */
229    public function copy(string $source, string $destination, $config = null): void
230    {
231        $source = $this->pathNormalizer->normalizePath($source);
232        $destination = $this->pathNormalizer->normalizePath($destination);
233
234        $sourceFile = $this->read($source);
235
236        $this->inMemoryFiles->write(
237            $destination,
238            $sourceFile,
239            $config instanceof Config ? $config : new Config($config ?? [])
240        );
241
242        $a = $this->inMemoryFiles->read($destination);
243        if ($sourceFile !== $a) {
244            throw new Exception('Copy failed');
245        }
246
247        if ($this->deletedFiles->fileExists($destination)) {
248            $this->deletedFiles->delete($destination);
249        }
250    }
251
252    /**
253     * @throws FilesystemException
254     */
255    private function getAttributes(string $path): StorageAttributes
256    {
257        $path = $this->pathNormalizer->normalizePath($path);
258
259        $parentDirectoryContents = $this->listContents(dirname($path), false);
260        /** @var FileAttributes $entry */
261        foreach ($parentDirectoryContents as $entry) {
262            if ($entry->path() == $path) {
263                return $entry;
264            }
265        }
266        throw UnableToReadFile::fromLocation($path);
267    }
268
269    public function lastModified(string $path): FileAttributes
270    {
271//        $attributes = $this->getAttributes($this->pathNormalizer->normalizePath($path));
272//        return $attributes->lastModified() ?? 0;
273        $storageAttributes = $this->getAttributes($path);
274        return new FileAttributes(
275            $path,
276            null,
277            $storageAttributes->visibility(),
278            // TODO: This shouldn't be null – it should be set during other operations.
279            $storageAttributes->lastModified() ?? 0
280        );
281    }
282
283    /**
284     * @see FilesystemAdapter::fileSize()
285     */
286    public function fileSize(string $path): FileAttributes
287    {
288        $path = $this->pathNormalizer->normalizePath($path);
289
290        switch (true) {
291            case ($this->deletedFiles->fileExists($path)):
292                return $this->deletedFiles->fileSize($path);
293            case ($this->inMemoryFiles->fileExists($path)):
294                return $this->inMemoryFiles->fileSize($path);
295            case ($this->delegateFilesystemAdapter->fileExists($path)):
296                return  $this->delegateFilesystemAdapter->fileSize($path);
297            default:
298                throw UnableToRetrieveMetadata::fileSize($path);
299        }
300    }
301
302    /**
303     * @see FilesystemAdapter::mimeType()
304     */
305    public function mimeType(string $path): FileAttributes
306    {
307        throw new BadMethodCallException('Not yet implemented');
308    }
309
310    public function setVisibility(string $path, string $visibility): void
311    {
312        throw new BadMethodCallException('Not yet implemented');
313    }
314
315    /**
316     * @see FilesystemAdapter::visibility()
317     * @see LocalFilesystemAdapter::visibility()
318     */
319    public function visibility(string $path): FileAttributes
320    {
321        $defaultVisibility = Visibility::PUBLIC;
322
323        $path = $this->pathNormalizer->normalizePath($path);
324
325        if (!$this->fileExists($path) && !$this->directoryExists($path)) {
326            throw UnableToRetrieveMetadata::visibility($path, 'file does not exist');
327        }
328
329        if ($this->deletedFiles->fileExists($path) || $this->deletedFiles->directoryExists($path)) {
330            throw UnableToRetrieveMetadata::visibility($path, 'file does not exist');
331        }
332
333        if ($this->inMemoryFiles->fileExists($path) || $this->inMemoryFiles->directoryExists($path)) {
334            return $this->inMemoryFiles->visibility($path);
335        }
336
337        return $this->delegateFilesystemAdapter->visibility($path);
338    }
339
340    public function directoryExists(string $path): bool
341    {
342        $path = $this->pathNormalizer->normalizePath($path);
343
344        if ($this->directoryExistsIn($path, $this->deletedFiles)) {
345            return false;
346        }
347
348        return  $this->directoryExistsIn($path, $this->inMemoryFiles)
349            || $this->directoryExistsIn($path, $this->delegateFilesystemAdapter);
350    }
351
352    /**
353     *
354     * @param string $path
355     * @param FilesystemAdapter $filesystemAdapter
356     * @return bool
357     * @throws FilesystemException
358     */
359    protected function directoryExistsIn(string $path, FilesystemAdapter $filesystemAdapter): bool
360    {
361        $path = $this->pathNormalizer->normalizePath($path);
362
363        if (method_exists($filesystemAdapter, 'directoryExists')) {
364            return $filesystemAdapter->directoryExists($path);
365        }
366
367        $parentDirectoryPath = dirname($path);
368
369        /** @var FileSystemReader $filesystemAdapter */
370        $parentDirectoryContents = $filesystemAdapter->listContents(
371            $this->pathNormalizer->normalizePath($parentDirectoryPath),
372            false
373        );
374
375        $parent = [];
376        /** @var FileAttributes $entry */
377        foreach ($parentDirectoryContents as $entry) {
378            $parent[] = $entry;
379            if ($entry->path() == $path) {
380                return $entry->isDir();
381            }
382        }
383
384        return false;
385    }
386
387    /**
388     * @see FlysystemReaderBackCompatTrait::directoryExists()
389     */
390    public function getNormalizer(): PathNormalizer
391    {
392        return $this->pathNormalizer;
393    }
394
395    public function normalizePath(string $path): string
396    {
397        return $this->pathNormalizer->normalizePath($path);
398    }
399}