Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
198 / 198
100.00% covered (success)
100.00%
30 / 30
CRAP
100.00% covered (success)
100.00%
1 / 1
ReadOnlyFileSystemAdapter
100.00% covered (success)
100.00%
198 / 198
100.00% covered (success)
100.00%
30 / 30
83
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getAdapter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fileExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 write
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 writeStream
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 rewindStream
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 toConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 read
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 readStream
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 deleteDirectory
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isDirectoryDeleted
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 undeleteAncestorDirectories
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 directoryKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createDirectory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 listContents
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 toArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 move
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 copy
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getAttributes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 lastModified
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 fileSize
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 mimeType
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 setVisibility
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 visibility
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 directoryExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 directoryExistsIn
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 directoryExistsByListing
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getNormalizer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * A read-only filesystem adapter, e.g. for use when running with `--dry-run`.
4 *
5 * Read operations work as normal but write operations are cached in memory so they appear to have been
6 * successful without ever being written to the delegate filesystem.
7 *
8 * Three layers are consulted, in order:
9 *
10 * 1. deleted files – tombstones recorded by {@see self::delete()} and {@see self::deleteDirectory()}
11 * 2. modified files – everything written during this session
12 * 3. the delegate adapter – the real filesystem, which is never mutated
13 *
14 * @package BrianHenryIE\FlysystemReadOnly
15 */
16
17namespace BrianHenryIE\FlysystemReadOnly;
18
19use League\Flysystem\Config;
20use League\Flysystem\DirectoryListing;
21use League\Flysystem\FileAttributes;
22use League\Flysystem\FilesystemAdapter;
23use League\Flysystem\FilesystemException;
24use League\Flysystem\InvalidVisibilityProvided;
25use League\Flysystem\PathNormalizer;
26use League\Flysystem\StorageAttributes;
27use League\Flysystem\UnableToCopyFile;
28use League\Flysystem\UnableToMoveFile;
29use League\Flysystem\UnableToReadFile;
30use League\Flysystem\UnableToRetrieveMetadata;
31use League\Flysystem\UnableToSetVisibility;
32use League\Flysystem\Visibility;
33use League\Flysystem\WhitespacePathNormalizer;
34use Traversable;
35
36/**
37 * An adapter which wraps another: reads pass through, writes and deletes are captured in memory.
38 */
39class ReadOnlyFileSystemAdapter implements FilesystemAdapter, FlysystemAdapterBackCompatTraitInterface
40{
41    protected FilesystemAdapter $delegateFilesystemAdapter;
42    protected ModifiedFilesInMemoryFilesystemAdapter $inMemoryFiles;
43    protected DeletedFilesInMemoryFilesystemAdapter $deletedFiles;
44
45    protected PathNormalizer $pathNormalizer;
46
47    /**
48     * Directories deleted during this session, keyed on the normalized path.
49     *
50     * Directory tombstones are tracked here rather than in {@see self::$deletedFiles} because that layer
51     * is a filesystem: a tombstone for `dir/file.php` would make `dir` itself look deleted.
52     *
53     * @var array<string, bool>
54     */
55    protected array $deletedDirectories = [];
56
57    /**
58     * Constructor.
59     *
60     * @param FilesystemAdapter   $delegateFilesystem The filesystem to read from and to protect from writes.
61     * @param PathNormalizer|null $pathNormalizer     Must be idempotent – it is applied by this adapter and
62     *                                                again by the in-memory layers it delegates to.
63     */
64    public function __construct(
65        FilesystemAdapter $delegateFilesystem,
66        ?PathNormalizer $pathNormalizer = null
67    ) {
68        $this->delegateFilesystemAdapter = $delegateFilesystem;
69
70        $this->pathNormalizer = $pathNormalizer ?? new WhitespacePathNormalizer();
71
72        $this->inMemoryFiles = new ModifiedFilesInMemoryFilesystemAdapter(
73            Visibility::PUBLIC,
74            null,
75            $this->pathNormalizer
76        );
77        $this->deletedFiles = new DeletedFilesInMemoryFilesystemAdapter(
78            Visibility::PUBLIC,
79            null,
80            $this->pathNormalizer
81        );
82    }
83
84    /**
85     * Return the wrapped adapter, i.e. the real filesystem this one protects from writes.
86     */
87    public function getAdapter(): FilesystemAdapter
88    {
89        return $this->delegateFilesystemAdapter;
90    }
91
92    /**
93     * Determine whether a file exists in memory or on the delegate filesystem, but not in the deleted files filesystem.
94     *
95     * @param string $location The path to check.
96     */
97    public function fileExists(string $location): bool
98    {
99        $location = $this->pathNormalizer->normalizePath($location);
100
101        if ($this->deletedFiles->fileExists($location)) {
102            return false;
103        }
104        return $this->inMemoryFiles->fileExists($location)
105               || $this->delegateFilesystemAdapter->fileExists($location);
106    }
107
108    /**
109     * Write a file to the in-memory layer, leaving the delegate filesystem untouched.
110     *
111     * @param string                           $location The path to write to.
112     * @param string                           $contents The file's contents.
113     * @param Config|array{visibility?:string} $config   Flysystem's per-operation config, e.g. visibility.
114     *
115     * @throws FilesystemException
116     */
117    public function write(string $location, string $contents, $config = []): void
118    {
119        $location = $this->pathNormalizer->normalizePath($location);
120
121        $this->inMemoryFiles->write($location, $contents, $this->toConfig($config));
122
123        $this->undeleteAncestorDirectories($location);
124
125        if ($this->deletedFiles->fileExists($location)) {
126            $this->deletedFiles->delete($location);
127        }
128    }
129
130    /**
131     * Write a stream to the in-memory layer, leaving the delegate filesystem untouched.
132     *
133     * @param string                           $path     The path to write to.
134     * @param resource                         $contents The stream to read the contents from.
135     * @param Config|array{visibility?:string} $config   Flysystem's per-operation config, e.g. visibility.
136     *
137     * @throws FilesystemException
138     *
139     * @see FilesystemAdapter::writeStream()
140     */
141    public function writeStream(string $path, $contents, $config = []): void
142    {
143        $path = $this->pathNormalizer->normalizePath($path);
144
145        $this->rewindStream($contents);
146        $this->inMemoryFiles->writeStream($path, $contents, $this->toConfig($config));
147
148        $this->undeleteAncestorDirectories($path);
149
150        if ($this->deletedFiles->fileExists($path)) {
151            $this->deletedFiles->delete($path);
152        }
153    }
154
155    /**
156     * Rewind a seekable stream which has already been read from, so its full contents are written.
157     *
158     * @param resource $resource The stream to rewind.
159     */
160    private function rewindStream($resource): void
161    {
162        if (ftell($resource) !== 0 && stream_get_meta_data($resource)['seekable']) {
163            rewind($resource);
164        }
165    }
166
167    /**
168     * Accept the array form Flysystem 2 callers pass, as well as a `Config` object.
169     *
170     * @param Config|array<string,mixed>|null $config The config to normalize.
171     */
172    private function toConfig($config): Config
173    {
174        return $config instanceof Config ? $config : new Config($config ?? []);
175    }
176
177    /**
178     * Read a file, preferring the in-memory version where this session has written one.
179     *
180     * @param string $path The path to read.
181     */
182    public function read(string $path): string
183    {
184        $path = $this->pathNormalizer->normalizePath($path);
185
186        if ($this->deletedFiles->fileExists($path)) {
187            throw UnableToReadFile::fromLocation($path);
188        }
189        if ($this->inMemoryFiles->fileExists($path)) {
190            return $this->inMemoryFiles->read($path);
191        }
192        return $this->delegateFilesystemAdapter->read($path);
193    }
194
195    /**
196     * Read a file as a stream, preferring the in-memory version where this session has written one.
197     *
198     * @param string $path The path to read.
199     *
200     * @return resource
201     */
202    public function readStream(string $path)
203    {
204        $path = $this->pathNormalizer->normalizePath($path);
205
206        if ($this->deletedFiles->fileExists($path)) {
207            throw UnableToReadFile::fromLocation($path);
208        }
209        if ($this->inMemoryFiles->fileExists($path)) {
210            return $this->inMemoryFiles->readStream($path);
211        }
212        return $this->delegateFilesystemAdapter->readStream($path);
213    }
214
215    /**
216     * Record the file as deleted, keeping a copy so it can still be restored by a later write.
217     *
218     * @param string $path The path to delete.
219     */
220    public function delete(string $path): void
221    {
222        $path = $this->pathNormalizer->normalizePath($path);
223
224        if ($this->fileExists($path)) {
225            $file = $this->read($path);
226            $this->deletedFiles->write($path, $file, new Config([]));
227        }
228
229        if ($this->inMemoryFiles->fileExists($path)) {
230            $this->inMemoryFiles->delete($path);
231        }
232    }
233
234    /**
235     * Record the directory as deleted.
236     *
237     * Note: files inside the directory which exist only on the delegate filesystem are not individually
238     * tombstoned; they remain readable by path. See the "Known limitations" section of the README.
239     *
240     * @param string $path The directory to delete.
241     */
242    public function deleteDirectory(string $path): void
243    {
244        $path = $this->directoryKey($path);
245
246        $this->deletedDirectories[$path] = true;
247        $this->inMemoryFiles->deleteDirectory($path);
248    }
249
250    /**
251     * Has this directory, or any directory containing it, been deleted?
252     *
253     * @param string $path The directory to check.
254     */
255    protected function isDirectoryDeleted(string $path): bool
256    {
257        $path = $this->directoryKey($path);
258
259        foreach (array_keys($this->deletedDirectories) as $deletedDirectory) {
260            if ($path === $deletedDirectory || 0 === strpos($path, $deletedDirectory . '/')) {
261                return true;
262            }
263        }
264
265        return false;
266    }
267
268    /**
269     * Creating a directory, or writing a file into one, brings back any deleted directory containing it.
270     *
271     * @param string $path The path whose ancestor directories to restore.
272     */
273    protected function undeleteAncestorDirectories(string $path): void
274    {
275        $path = $this->directoryKey($path);
276
277        while ($path !== '' && $path !== '.' && $path !== '/') {
278            $path = $this->directoryKey(dirname($path));
279
280            unset($this->deletedDirectories[$path]);
281
282            if ($path === '.' || $path === '/') {
283                break;
284            }
285        }
286    }
287
288    /**
289     * Return the key a directory is tracked under: normalized, without a trailing slash.
290     *
291     * @param string $path The directory path to key.
292     */
293    protected function directoryKey(string $path): string
294    {
295        return rtrim($this->pathNormalizer->normalizePath($path), '/');
296    }
297
298    /**
299     * Create a directory in the in-memory layer, restoring it if it had been deleted.
300     *
301     * @param string                           $path   The directory to create.
302     * @param Config|array{visibility?:string} $config Flysystem's per-operation config, e.g. visibility.
303     *
304     * @throws FilesystemException
305     */
306    public function createDirectory(string $path, $config = []): void
307    {
308        $path = $this->pathNormalizer->normalizePath($path);
309
310        $this->inMemoryFiles->createDirectory($path, $this->toConfig($config));
311
312        unset($this->deletedDirectories[$this->directoryKey($path)]);
313        $this->undeleteAncestorDirectories($path);
314    }
315
316    /**
317     * List a directory's contents, merging the three layers: deleted files are omitted and
318     * modified files replace their delegate filesystem counterparts.
319     *
320     * @param string $path The directory to list.
321     * @param bool   $deep Whether to list the contents of subdirectories too.
322     *
323     * @return iterable<StorageAttributes>
324     */
325    public function listContents(string $path, bool $deep): iterable
326    {
327        $path = $this->pathNormalizer->normalizePath($path);
328
329        $deletedFilesArray = $this->toArray($this->deletedFiles->listContents($path, $deep));
330        $deletedFilePaths  = array_map(fn($file) => $file->path(), $deletedFilesArray);
331
332        $inMemoryFilesArray = $this->toArray($this->inMemoryFiles->listContents($path, $deep));
333
334        // Remove deleted files from the modified files filesystem array.
335        $inMemoryFilesArray = array_filter(
336            $inMemoryFilesArray,
337            fn($file) => !in_array($file->path(), $deletedFilePaths)
338        );
339
340        $inMemoryFilePaths = array_map(fn($file) => $file->path(), $inMemoryFilesArray);
341
342        $delegateFilesystemArray = $this->toArray($this->delegateFilesystemAdapter->listContents($path, $deep));
343
344        // Remove modified files from the delegate filesystem array.
345        $delegateFilesystemArray = array_filter(
346            $delegateFilesystemArray,
347            fn($file) => !in_array($file->path(), $inMemoryFilePaths)
348        );
349        // Remove deleted files from the delegate filesystem array.
350        $delegateFilesystemArray = array_filter(
351            $delegateFilesystemArray,
352            fn($file) => !in_array($file->path(), $deletedFilePaths)
353        );
354
355        return new DirectoryListing(array_merge($delegateFilesystemArray, $inMemoryFilesArray));
356    }
357
358    /**
359     * Materialize a directory listing so it can be filtered and merged.
360     *
361     * @param iterable<StorageAttributes> $listing The listing to materialize.
362     *
363     * @return array<StorageAttributes>
364     */
365    private function toArray(iterable $listing): array
366    {
367        return $listing instanceof Traversable
368            ? iterator_to_array($listing, false)
369            : (array) $listing;
370    }
371
372    /**
373     * Copy the file to its destination in memory, then record the source as deleted.
374     *
375     * @param string                                $source      The path to move from.
376     * @param string                                $destination The path to move to.
377     * @param Config|array{visibility?:string}|null $config      Flysystem's per-operation config.
378     *
379     * @throws FilesystemException
380     */
381    public function move(string $source, string $destination, $config = null): void
382    {
383        $source      = $this->pathNormalizer->normalizePath($source);
384        $destination = $this->pathNormalizer->normalizePath($destination);
385
386        if ($source === $destination) {
387            return;
388        }
389
390        if (!$this->fileExists($source)) {
391            throw UnableToMoveFile::fromLocationTo($source, $destination);
392        }
393
394        try {
395            $this->copy($source, $destination, $config);
396            $this->delete($source);
397        } catch (FilesystemException $exception) {
398            throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
399        }
400    }
401
402    /**
403     * Copy the file to its destination in the in-memory layer.
404     *
405     * @param string                                $source      The path to copy from.
406     * @param string                                $destination The path to copy to.
407     * @param Config|array{visibility?:string}|null $config      Flysystem's per-operation config.
408     *
409     * @throws FilesystemException
410     *
411     * @see FilesystemAdapter::copy()
412     */
413    public function copy(string $source, string $destination, $config = null): void
414    {
415        $source      = $this->pathNormalizer->normalizePath($source);
416        $destination = $this->pathNormalizer->normalizePath($destination);
417
418        if (!$this->fileExists($source)) {
419            throw UnableToCopyFile::fromLocationTo($source, $destination);
420        }
421
422        try {
423            $this->inMemoryFiles->write($destination, $this->read($source), $this->toConfig($config));
424        } catch (FilesystemException $exception) {
425            throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
426        }
427
428        if ($this->deletedFiles->fileExists($destination)) {
429            $this->deletedFiles->delete($destination);
430        }
431    }
432
433    /**
434     * Find a path's attributes by listing its parent directory, so all three layers are consulted.
435     *
436     * @param string $path The path to describe.
437     *
438     * @throws FilesystemException
439     */
440    private function getAttributes(string $path): StorageAttributes
441    {
442        $path = $this->pathNormalizer->normalizePath($path);
443
444        $parentDirectoryContents = $this->listContents(dirname($path), false);
445        /**
446         * Listings yield `StorageAttributes`; both its implementations expose `::path()`.
447         *
448         * @var FileAttributes $entry
449         */
450        foreach ($parentDirectoryContents as $entry) {
451            if ($entry->path() == $path) {
452                return $entry;
453            }
454        }
455        throw UnableToRetrieveMetadata::lastModified($path, 'file does not exist');
456    }
457
458    /**
459     * Return the modified time of a file, from whichever layer provides it.
460     *
461     * @param string $path The path to read the modified time of.
462     */
463    public function lastModified(string $path): FileAttributes
464    {
465        $path = $this->pathNormalizer->normalizePath($path);
466
467        $storageAttributes = $this->getAttributes($path);
468
469        return new FileAttributes(
470            $path,
471            null,
472            $storageAttributes->visibility(),
473            $storageAttributes->lastModified() ?? 0
474        );
475    }
476
477    /**
478     * Return the size of a file, from whichever layer provides it.
479     *
480     * @param string $path The path to read the size of.
481     *
482     * @see FilesystemAdapter::fileSize()
483     */
484    public function fileSize(string $path): FileAttributes
485    {
486        $path = $this->pathNormalizer->normalizePath($path);
487
488        switch (true) {
489            case $this->deletedFiles->fileExists($path):
490                throw UnableToRetrieveMetadata::fileSize($path, 'file does not exist');
491            case $this->inMemoryFiles->fileExists($path):
492                $fileSize = $this->inMemoryFiles->fileSize($path)->fileSize();
493                break;
494            case $this->delegateFilesystemAdapter->fileExists($path):
495                $fileSize = $this->delegateFilesystemAdapter->fileSize($path)->fileSize();
496                break;
497            default:
498                throw UnableToRetrieveMetadata::fileSize($path, 'file does not exist');
499        }
500
501        return new FileAttributes($path, $fileSize);
502    }
503
504    /**
505     * Return the MIME type of a file, from whichever layer provides it.
506     *
507     * @param string $path The path to read the MIME type of.
508     *
509     * @see FilesystemAdapter::mimeType()
510     */
511    public function mimeType(string $path): FileAttributes
512    {
513        $path = $this->pathNormalizer->normalizePath($path);
514
515        switch (true) {
516            case $this->deletedFiles->fileExists($path):
517                throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist');
518            case $this->inMemoryFiles->fileExists($path):
519                $mimeType = $this->inMemoryFiles->mimeType($path)->mimeType();
520                break;
521            case $this->delegateFilesystemAdapter->fileExists($path):
522                $mimeType = $this->delegateFilesystemAdapter->mimeType($path)->mimeType();
523                break;
524            default:
525                throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist');
526        }
527
528        return new FileAttributes($path, null, null, null, $mimeType);
529    }
530
531    /**
532     * Set the visibility of a file or directory in the in-memory layer.
533     *
534     * The delegate filesystem is never modified: a file which exists only there is first copied into the
535     * in-memory layer, then its visibility is set there.
536     *
537     * @param string $path       The path whose visibility to set.
538     * @param string $visibility One of {@see Visibility}'s constants.
539     *
540     * @throws FilesystemException
541     */
542    public function setVisibility(string $path, string $visibility): void
543    {
544        if (!in_array($visibility, [Visibility::PUBLIC, Visibility::PRIVATE], true)) {
545            throw InvalidVisibilityProvided::withVisibility($visibility, "either 'public' or 'private'");
546        }
547
548        $path = $this->pathNormalizer->normalizePath($path);
549
550        if ($this->deletedFiles->fileExists($path)) {
551            throw UnableToSetVisibility::atLocation($path, 'file does not exist');
552        }
553
554        if ($this->inMemoryFiles->fileExists($path)) {
555            $this->inMemoryFiles->setVisibility($path, $visibility);
556            return;
557        }
558
559        if ($this->delegateFilesystemAdapter->fileExists($path)) {
560            // Copy on write.
561            $this->inMemoryFiles->write(
562                $path,
563                $this->delegateFilesystemAdapter->read($path),
564                new Config([Config::OPTION_VISIBILITY => $visibility])
565            );
566            return;
567        }
568
569        if ($this->directoryExists($path)) {
570            $directoryConfig = new Config(
571                [
572                    Config::OPTION_VISIBILITY           => $visibility,
573                    Config::OPTION_DIRECTORY_VISIBILITY => $visibility,
574                ]
575            );
576            $this->inMemoryFiles->createDirectory($path, $directoryConfig);
577            $this->inMemoryFiles->setVisibility($path, $visibility);
578            return;
579        }
580
581        throw UnableToSetVisibility::atLocation($path, 'file does not exist');
582    }
583
584    /**
585     * Return the visibility of a file or directory, from whichever layer provides it.
586     *
587     * @param string $path The path to read the visibility of.
588     *
589     * @see FilesystemAdapter::visibility()
590     * @see \League\Flysystem\Local\LocalFilesystemAdapter::visibility()
591     */
592    public function visibility(string $path): FileAttributes
593    {
594        $path = $this->pathNormalizer->normalizePath($path);
595
596        if (!$this->fileExists($path) && !$this->directoryExists($path)) {
597            throw UnableToRetrieveMetadata::visibility($path, 'file does not exist');
598        }
599
600        $visibility = $this->inMemoryFiles->fileExists($path) || $this->inMemoryFiles->directoryExists($path)
601            ? $this->inMemoryFiles->visibility($path)->visibility()
602            : $this->delegateFilesystemAdapter->visibility($path)->visibility();
603
604        return new FileAttributes($path, null, $visibility);
605    }
606
607    /**
608     * Determine whether a directory exists, unless this session has deleted it.
609     *
610     * @param string $path The path to check.
611     */
612    public function directoryExists(string $path): bool
613    {
614        $path = $this->pathNormalizer->normalizePath($path);
615
616        if ($this->isDirectoryDeleted($path)) {
617            return false;
618        }
619
620        return $this->directoryExistsIn($path, $this->inMemoryFiles)
621            || $this->directoryExistsIn($path, $this->delegateFilesystemAdapter);
622    }
623
624    /**
625     * Determine whether a directory exists in one particular adapter.
626     *
627     * `FilesystemAdapter::directoryExists()` was introduced in `league/flysystem` v3; when it is absent
628     * fall back to listing the parent directory.
629     *
630     * @param string            $path              The path to check.
631     * @param FilesystemAdapter $filesystemAdapter The adapter to check in.
632     *
633     * @throws FilesystemException
634     */
635    protected function directoryExistsIn(string $path, FilesystemAdapter $filesystemAdapter): bool
636    {
637        $path = $this->pathNormalizer->normalizePath($path);
638
639        if (method_exists($filesystemAdapter, 'directoryExists')) {
640            // v2's `FilesystemAdapter` does not declare `::directoryExists()`, so its return value is
641            // untyped there; anything other than `true` is treated as the directory not existing.
642            return true === $filesystemAdapter->directoryExists($path);
643        }
644
645        return $this->directoryExistsByListing($path, $filesystemAdapter);
646    }
647
648    /**
649     * Determine whether a directory exists by listing its parent directory and looking for it.
650     *
651     * @param string            $path              The path to check.
652     * @param FilesystemAdapter $filesystemAdapter The adapter to list from.
653     *
654     * @throws FilesystemException
655     */
656    protected function directoryExistsByListing(string $path, FilesystemAdapter $filesystemAdapter): bool
657    {
658        $path = $this->pathNormalizer->normalizePath($path);
659
660        $parentDirectoryPath = $this->pathNormalizer->normalizePath(dirname($path));
661
662        if ($parentDirectoryPath === $path) {
663            // The root directory must exist.
664            return true;
665        }
666
667        /**
668         * Listings yield `StorageAttributes`; both its implementations expose `::isDir()`.
669         *
670         * @var FileAttributes $entry
671         */
672        foreach ($filesystemAdapter->listContents($parentDirectoryPath, false) as $entry) {
673            if ($entry->path() == $path) {
674                return $entry->isDir();
675            }
676        }
677
678        return false;
679    }
680
681    /**
682     * Return the normalizer this adapter and its in-memory layers share.
683     */
684    public function getNormalizer(): PathNormalizer
685    {
686        return $this->pathNormalizer;
687    }
688
689    /**
690     * Return the path in its canonical form, as this adapter stores it.
691     *
692     * @param string $path The path to normalize.
693     */
694    public function normalizePath(string $path): string
695    {
696        return $this->pathNormalizer->normalizePath($path);
697    }
698}