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