Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 369
0.00% covered (danger)
0.00%
0 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
SymlinkProtectFilesystemAdapter
0.00% covered (danger)
0.00%
0 / 369
0.00% covered (danger)
0.00%
0 / 27
15006
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getSymlinkDetails
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getSymlinkInPath
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 recordSymlink
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 isSymlinked
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSymlinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeSymlink
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 isWindowsOS
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
 write
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 writeStream
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 setVisibility
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 delete
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
90
 deleteDirectory
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
90
 createDirectory
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 move
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
342
 copy
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
 fileExists
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 read
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 readStream
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 visibility
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 mimeType
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 lastModified
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 fileSize
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 listContents
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
156
 parentListDirectoryRecursively
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 parentListDirectory
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * The idea is to prevent write/delete operations to symlinked files.
4 *
5 * This class extends LocalFilesystemAdapter and adds two new `linkHandling` modes: warn and throw.
6 * * LocalFilesystemAdapter::SKIP_LINKS Silently does nothing (e.g. symlinks are not included in directory lists)
7 * * LocalFilesystemAdapter::DISALLOW_LINKS Throws exceptions when symlinks are encountered for read or write
8 * * SymlinkProtectFilesystemAdapter::WARN_LINKS Sends a message to LoggerInterface::warning() with appropriate context,
9 *                                               then allows write operations.
10 * * SymlinkProtectFilesystemAdapter::THROW_LINKS Throws UnableToWriteFile exception on write to a symlinked path
11 *
12 * Read operations act normally.
13 * `::delete()` and `::deleteDirectory()` unlinks symlinks, messages LoggerInterface::notice().
14 *
15 * Outcome of read operation on symlinked files:
16 * * Debug log every time a symlinked file is read
17 *
18 * Outcome of write/modify operation on non-symlinked files:
19 * * standard behavior of LocalFilesystemAdapter
20 *
21 * Info log the first time each symlink is seen
22 *
23 * Your implementation of LoggerInterface can decide what to do with the log messages, e.g.
24 * throw an exception on error, or just log them with an instruction on how to remedy the issue.
25 *
26 * @see \League\Flysystem\SymbolicLinkEncountered
27 * @see \League\Flysystem\Local\LocalFilesystemAdapter::SKIP_LINKS
28 * @see \League\Flysystem\Local\LocalFilesystemAdapter::DISALLOW_LINKS
29 * @see \League\Flysystem\Local\LocalFilesystemAdapter::$linkHandling
30 * @see \League\Flysystem\Local\LocalFilesystemAdapter::listContents()
31 */
32
33namespace BrianHenryIE\Strauss\Helpers\Flysystem;
34
35use DirectoryIterator;
36use FilesystemIterator;
37use Generator;
38use League\Flysystem\Config;
39use League\Flysystem\DirectoryAttributes;
40use League\Flysystem\FileAttributes;
41use League\Flysystem\FilesystemAdapter;
42use League\Flysystem\FilesystemException;
43use League\Flysystem\Local\LocalFilesystemAdapter;
44use League\Flysystem\PathNormalizer;
45use League\Flysystem\SymbolicLinkEncountered;
46use League\Flysystem\UnableToCopyFile;
47use League\Flysystem\UnableToCreateDirectory;
48use League\Flysystem\UnableToDeleteDirectory;
49use League\Flysystem\UnableToDeleteFile;
50use League\Flysystem\UnableToMoveFile;
51use League\Flysystem\UnableToReadFile;
52use League\Flysystem\UnableToSetVisibility;
53use League\Flysystem\UnableToWriteFile;
54use League\Flysystem\UnixVisibility\PortableVisibilityConverter;
55use League\Flysystem\UnixVisibility\VisibilityConverter;
56use League\Flysystem\WhitespacePathNormalizer;
57use League\MimeTypeDetection\MimeTypeDetector;
58use Psr\Log\LoggerAwareTrait;
59use Psr\Log\LoggerInterface;
60use Psr\Log\NullLogger;
61use RecursiveDirectoryIterator;
62use RecursiveIteratorIterator;
63use RuntimeException;
64use SplFileInfo;
65
66class SymlinkProtectFilesystemAdapter extends LocalFilesystemAdapter implements FlysystemAdapterBackCompatTraitInterface
67{
68    use FlysystemAdapterBackCompatTrait;
69    use LoggerAwareTrait;
70
71    public const WARN_LINKS = 0003;
72
73    /**
74     * @var int
75     */
76    public const THROW_LINKS = 0004;
77
78    /**
79     * Converts flysystem relative paths to filesystem absolute paths.
80     *
81     * @see LocalFilesystemAdapter::$prefixer
82     *
83     * @var \BrianHenryIE\Strauss\Helpers\Flysystem\PathPrefixerInterface|\League\Flysystem\PathPrefixer
84     */
85    protected $pathPrefixer;
86
87    /**
88     * @see LocalFilesystemAdapter::$linkHandling
89     */
90    protected int $linkHandling;
91
92    /**
93     * @see LocalFilesystemAdapter::$visibility
94     * @var VisibilityConverter
95     */
96    private $visibility;
97
98    /**
99     * E.g. {@see WhitespacePathNormalizer} rejects invalid whitespace, converts `\` to `/`, and converts relative to absolute paths.
100     */
101    protected PathNormalizer $normalizer;
102
103    /**
104     * Record of discovered symlink paths
105     * * prevent notifying more than once per discovered symlink
106     * * allows faster lookup in future
107     * * provides list of symlinked paths for "did we encounter any symlink" checks post operations
108     *
109     * @var array<string, string> Array of absolute filesystem paths : symlink target (`realpath()`).
110     */
111    protected array $symlinkTargetsMap = [];
112
113    /**
114     * @param \League\Flysystem\PathPrefixer|PathPrefixerInterface $pathPrefixer
115     */
116    public function __construct(
117        string $location,
118        ?PathNormalizer $pathNormalizer = null,
119        $pathPrefixer = null,
120        ?LoggerInterface $logger = null,
121        ?VisibilityConverter $visibility = null,
122        int $writeFlags = LOCK_EX,
123        int $linkHandling = SymlinkProtectFilesystemAdapter::THROW_LINKS,
124        ?MimeTypeDetector $mimeTypeDetector = null
125    ) {
126        parent::__construct($location, $visibility, $writeFlags, $linkHandling, $mimeTypeDetector);
127
128        $this->normalizer = $pathNormalizer ?? new WhitespacePathNormalizer();
129        $this->pathPrefixer = $pathPrefixer ?? new PathPrefixer($location, DIRECTORY_SEPARATOR);
130        $this->setLogger($logger ?? new NullLogger());
131        $this->visibility = $visibility ?: new PortableVisibilityConverter();
132        $this->linkHandling = $linkHandling;
133    }
134
135    /**
136     * @param string $path
137     */
138    protected function getSymlinkDetails(string $path): ?PathSymlinkDetails
139    {
140        $absoluteFilesystemPath = $this->pathPrefixer->prefixPath($path);
141        $realpath = realpath($absoluteFilesystemPath);
142
143        if ($realpath === false) {
144            // File not found.
145            throw new RuntimeException('Unable to realpath() absolute path: ' . $absoluteFilesystemPath);
146        }
147        if ($absoluteFilesystemPath === $realpath) {
148            return null;
149        }
150
151        $symlink = $this->getSymlinkInPath($absoluteFilesystemPath);
152
153        if (is_null($symlink)) {
154            return null;
155        }
156
157        return new PathSymlinkDetails(
158            $path,
159            $absoluteFilesystemPath,
160            $realpath,
161            $symlink,
162            $this->symlinkTargetsMap[ $symlink ]
163        );
164    }
165
166    /**
167     * Check is a file within a symlinked directory.
168     *
169     * Recursive function that runs `is_link()` on the path, drops the last part, loops until found or throws.
170     *
171     * @see \SplFileInfo::isLink()
172     *
173     * @see https://github.com/php/php-src/issues/12118
174     *
175     * @return ?string The filesystem path to the symlink, or null if none found.
176     */
177    protected function getSymlinkInPath(string $absoluteFilesystemPath): ?string
178    {
179        if ($absoluteFilesystemPath === '.' || $absoluteFilesystemPath === '/') {
180            return null;
181        }
182
183        // Exact path is the symlink and it has already been recorded.
184        if (isset($this->symlinkTargetsMap[ $absoluteFilesystemPath ])) {
185            return $absoluteFilesystemPath;
186        }
187
188        if (is_link($absoluteFilesystemPath)) {
189            $realpath = realpath($absoluteFilesystemPath);
190            if ($realpath === false) {
191//                return $this->getSymlinkInPath(dirname($absoluteFilesystemPath));
192                throw new RuntimeException('symlink error');
193            }
194            $this->recordSymlink($absoluteFilesystemPath, $realpath);
195            return $absoluteFilesystemPath;
196        }
197
198        return $this->getSymlinkInPath(dirname($absoluteFilesystemPath));
199    }
200
201    protected function recordSymlink(string $absoluteFilesystemPath, string $symlinkTargetRealpath): void
202    {
203        if (isset($this->symlinkTargetsMap[$absoluteFilesystemPath])) {
204            return;
205        }
206
207        $this->symlinkTargetsMap[$absoluteFilesystemPath] = $symlinkTargetRealpath;
208
209        $this->logger->info(
210            "New symlink found at {source} target {target}.",
211            [
212                'source' => $absoluteFilesystemPath,
213                'target' => $symlinkTargetRealpath,
214            ]
215        );
216    }
217
218    /**
219     * Given the path to a file or folder, is it inside a symlinked directory?
220     */
221    public function isSymlinked(string $path): bool
222    {
223        $path = $this->normalizer->normalizePath($path);
224
225        return !empty($this->getSymlinkDetails($path));
226    }
227
228    /**
229     * @return array<string, string> Array of absolute paths which are symlinks : target realpath.
230     */
231    public function getSymlinks(): array
232    {
233        return $this->symlinkTargetsMap;
234    }
235
236    /**
237     * Deleting a symlink is different on Windows and Linux.
238     *
239     * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory.
240     * "On Windows, take care that `is_link()` returns false for Junctions."
241     *
242     * @see https://www.php.net/manual/en/function.is-link.php#113263
243     * @see https://stackoverflow.com/a/18262809/336146
244     * @throws FilesystemException
245     */
246    protected function removeSymlink(string $path): bool
247    {
248        $fullPath = $this->pathPrefixer->prefixPath($path);
249
250        $this->logger->notice('Deleting symlink at ' . $fullPath . ' (points to ' . realpath($fullPath) . ')');
251
252        if ($this->isWindowsOS()) {
253            $success = rmdir($fullPath);
254        } else {
255            $success = unlink($fullPath);
256        }
257
258        return $success && (false === realpath($fullPath));
259    }
260
261    /**
262     * Check are we running on Windows, whose symlink behaviour differs.
263     *
264     * Copied from {@see \Composer\Util\Platform::isWindows()}.
265     */
266    protected function isWindowsOS(): bool
267    {
268        return \defined('PHP_WINDOWS_VERSION_BUILD');
269    }
270
271    /**
272     * @see FlysystemReaderBackCompatTrait::directoryExists()
273     */
274    public function normalizePath(string $path): string
275    {
276        return $this->normalizer->normalizePath($path);
277    }
278
279    /**
280     *
281     *
282     * @see FilesystemAdapter::write()
283     * @see FilesystemAdapter::ensureDirectoryExists()
284     */
285    public function write(string $path, string $contents, Config $config): void
286    {
287        $path = $this->normalizer->normalizePath($path);
288
289        $absoluteFilesystemPath = $this->pathPrefixer->prefixPath($path);
290        $symlink = $this->getSymlinkInPath($absoluteFilesystemPath);
291
292        if (!$symlink) {
293            parent::write($path, $contents, $config);
294            return;
295        }
296
297        $symlinkDetails = $this->getSymlinkDetails($this->pathPrefixer->stripPrefix($symlink));
298
299        switch ($this->linkHandling) {
300            case LocalFilesystemAdapter::SKIP_LINKS:
301                return;
302            case LocalFilesystemAdapter::DISALLOW_LINKS:
303                throw SymbolicLinkEncountered::atLocation($path);
304            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
305                $this->logger->warning(
306                    "Writing symlinked file {flysystemPath} realpath {targetAbsoluteFilesystemPath}",
307                    array_merge(
308                        ['operation' => __METHOD__],
309                        (array) $symlinkDetails
310                    )
311                );
312                parent::write($path, $contents, $config);
313                return;
314            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
315            default:
316                throw UnableToWriteFile::atLocation($path, 'symlink');
317        }
318    }
319
320    /**
321     * @throws FilesystemException
322     * @see FilesystemAdapter::writeStream()
323     */
324    public function writeStream(string $path, $contents, Config $config): void
325    {
326        $path = $this->normalizer->normalizePath($path);
327
328
329        $absoluteFilesystemPath = $this->pathPrefixer->prefixPath($path);
330        $symlink = $this->getSymlinkInPath($absoluteFilesystemPath);
331
332        if (!$symlink) {
333            parent::writeStream($path, $contents, $config);
334            return;
335        }
336
337        $symlinkDetails = $this->getSymlinkDetails($symlink);
338
339        switch ($this->linkHandling) {
340            case LocalFilesystemAdapter::SKIP_LINKS:
341                return;
342            case LocalFilesystemAdapter::DISALLOW_LINKS:
343                throw SymbolicLinkEncountered::atLocation($path);
344            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
345                $this->logger->warning(
346                    "Writing stream symlinked file {flysystemPath} realpath {targetAbsoluteFilesystemPath}",
347                    array_merge(
348                        ['operation' => __METHOD__],
349                        (array) $symlinkDetails
350                    )
351                );
352                parent::writeStream($path, $contents, $config);
353                return;
354            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
355            default:
356                throw UnableToWriteFile::atLocation($path, 'symlink');
357        }
358    }
359
360    public function setVisibility(string $path, string $visibility): void
361    {
362        $path = $this->normalizer->normalizePath($path);
363
364        $symlinkDetails = $this->getSymlinkDetails($path);
365        $isSymlinked = !empty($symlinkDetails);
366
367        if (!$isSymlinked) {
368            parent::setVisibility($path, $visibility);
369            return;
370        }
371
372        switch ($this->linkHandling) {
373            case LocalFilesystemAdapter::SKIP_LINKS:
374                return;
375            case LocalFilesystemAdapter::DISALLOW_LINKS:
376                throw SymbolicLinkEncountered::atLocation($path);
377            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
378                $this->logger->warning(
379                    "Setting visibility of symlinked file {flysystemPath} realpath {targetAbsoluteFilesystemPath}",
380                    array_merge(
381                        ['operation' => __METHOD__],
382                        (array) $symlinkDetails
383                    )
384                );
385                parent::setVisibility($path, $visibility);
386                return;
387            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
388            default:
389                throw UnableToSetVisibility::atLocation($path, 'symlink');
390        }
391    }
392
393    public function delete(string $path): void
394    {
395        $path = $this->normalizer->normalizePath($path);
396
397        $symlinkDetails = $this->getSymlinkDetails($path);
398        $isSymlinked = !empty($symlinkDetails);
399
400        if (!$isSymlinked) {
401            parent::delete($path);
402            return;
403        }
404
405        switch ($this->linkHandling) {
406            case LocalFilesystemAdapter::SKIP_LINKS:
407                return;
408            case LocalFilesystemAdapter::DISALLOW_LINKS:
409                throw SymbolicLinkEncountered::atLocation($path);
410            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
411                if ($symlinkDetails->isSymlink()) {
412                    $this->logger->warning(
413                        "Deleting symlink at {flysystemPath}",
414                        array_merge(
415                            [ 'operation' => __METHOD__ ],
416                            (array) $symlinkDetails
417                        )
418                    );
419                    $didRemove = $this->removeSymlink($path);
420
421                    if (!$didRemove) {
422                        throw UnableToDeleteFile::atLocation($path, 'symlink target: ' . $symlinkDetails->symlinkTargetRealpathAbsoluteFilesystemPath);
423                    }
424                    return;
425                } else {
426                    $this->logger->warning(
427                        "Deleting symlinked file {flysystemPath} realpath {targetAbsoluteFilesystemPath}",
428                        array_merge(
429                            [ 'operation' => __METHOD__ ],
430                            (array) $symlinkDetails
431                        )
432                    );
433                    parent::delete($path);
434
435                    return;
436                }
437            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
438            default:
439                throw UnableToDeleteFile::atLocation($path, 'symlink');
440        }
441    }
442
443    public function deleteDirectory(string $path): void
444    {
445        $path = $this->normalizer->normalizePath($path);
446
447        $symlinkDetails = $this->getSymlinkDetails($path);
448        $isSymlinked = !empty($symlinkDetails);
449
450        if (!$isSymlinked) {
451            parent::deleteDirectory($path);
452            return;
453        }
454
455        if ($symlinkDetails->isSymlink()) {
456            $this->logger->notice(
457                "Unlinking directory symlink {flysystemPath} realpath {targetAbsoluteFilesystemPath}",
458                array_merge(
459                    [ 'operation' => __METHOD__ ],
460                    (array) $symlinkDetails
461                )
462            );
463            $didRemove = $this->removeSymlink($path);
464
465            if (!$didRemove) {
466                throw UnableToDeleteFile::atLocation($path, 'symlink');
467            }
468            return;
469        }
470
471        switch ($this->linkHandling) {
472            case LocalFilesystemAdapter::SKIP_LINKS:
473                return;
474            case LocalFilesystemAdapter::DISALLOW_LINKS:
475                throw SymbolicLinkEncountered::atLocation($path);
476            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
477                $this->logger->warning(
478                    "Deleting directory {flysystemPath} under symlink {symlinkAbsoluteFilesystemPath} realpath {targetAbsoluteFilesystemPath}",
479                    array_merge(
480                        [ 'operation' => __METHOD__ ],
481                        (array) $symlinkDetails
482                    )
483                );
484                parent::deleteDirectory($path);
485
486                return;
487            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
488            default:
489                throw UnableToDeleteDirectory::atLocation($path, 'symlink');
490        }
491    }
492
493    /**
494     * Recursively create a directory.
495     * @throws FilesystemException
496     */
497    public function createDirectory(string $path, Config $config): void
498    {
499        $path = $this->normalizer->normalizePath($path);
500
501        $existingParentDir = $path;
502        do {
503            $existingParentDir = dirname($existingParentDir);
504        } while (!$this->directoryExists($existingParentDir));
505
506        $symlinkDetails = $this->getSymlinkDetails($existingParentDir);
507        $isSymlinked = !empty($symlinkDetails);
508
509        if (!$isSymlinked) {
510            parent::createDirectory($path, $config);
511            return;
512        }
513
514        switch ($this->linkHandling) {
515            case LocalFilesystemAdapter::SKIP_LINKS:
516                return;
517            case LocalFilesystemAdapter::DISALLOW_LINKS:
518                throw SymbolicLinkEncountered::atLocation($path);
519            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
520                $this->logger->warning(
521                    "Creating directory under {flysystemPath} symlink at {symlinkAbsoluteFilesystemPath}",
522                    array_merge(
523                        [ 'operation' => __METHOD__ ],
524                        (array) $symlinkDetails
525                    )
526                );
527                parent::createDirectory($path, $config);
528                return;
529            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
530            default:
531                throw UnableToCreateDirectory::atLocation($path, 'symlink');
532        }
533    }
534
535    public function move(string $source, string $destination, Config $config): void
536    {
537        $source = $this->normalizer->normalizePath($source);
538        $destination = $this->normalizer->normalizePath($destination);
539
540        try {
541            $sourceSymlinkDetails = $this->getSymlinkDetails($source);
542        } catch (RuntimeException $exception) {
543            // Source file not found.
544            throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
545        }
546        $isSourceSymlinked = !empty($sourceSymlinkDetails);
547        if ($isSourceSymlinked) {
548            switch ($this->linkHandling) {
549                case LocalFilesystemAdapter::SKIP_LINKS:
550                    return;
551                case LocalFilesystemAdapter::DISALLOW_LINKS:
552                    throw SymbolicLinkEncountered::atLocation($source);
553                case SymlinkProtectFilesystemAdapter::WARN_LINKS:
554                    break;
555                case SymlinkProtectFilesystemAdapter::THROW_LINKS:
556                default:
557                    throw UnableToMoveFile::fromLocationTo($source, $destination);
558            }
559        }
560
561        $existingDestinationParentDir = $destination;
562        do {
563            $existingDestinationParentDir = dirname($existingDestinationParentDir);
564        } while (!$this->directoryExists($existingDestinationParentDir));
565
566        $destinationSymlinkDetails = $this->getSymlinkDetails($existingDestinationParentDir);
567        $isDestinationSymlinked = !empty($destinationSymlinkDetails);
568
569        if ($isDestinationSymlinked) {
570            switch ($this->linkHandling) {
571                case LocalFilesystemAdapter::SKIP_LINKS:
572                    return;
573                case LocalFilesystemAdapter::DISALLOW_LINKS:
574                    throw SymbolicLinkEncountered::atLocation($destination);
575                case SymlinkProtectFilesystemAdapter::WARN_LINKS:
576                    break;
577                case SymlinkProtectFilesystemAdapter::THROW_LINKS:
578                default:
579                    throw UnableToMoveFile::fromLocationTo($source, $destination);
580            }
581        }
582
583        // TODO: improve this message.
584        $logMessage = '';
585        $logContext = [
586            'source' => $source,
587            'destination' => $destination,
588        ];
589        if ($isSourceSymlinked) {
590            $logMessage .= 'Source symlinked. ';
591            $logContext['sourceSymlinked'] = $sourceSymlinkDetails;
592        }
593        if ($isDestinationSymlinked) {
594            $logMessage .= 'Destination symlinked.';
595            $logContext['destinationSymlinked'] = $destinationSymlinkDetails;
596        }
597        if ($isSourceSymlinked || $isDestinationSymlinked) {
598            $this->logger->warning(trim($logMessage), $logContext);
599        }
600
601        parent::move($source, $destination, $config);
602    }
603
604    public function copy(string $source, string $destination, Config $config): void
605    {
606        $source = $this->normalizer->normalizePath($source);
607        $destination = $this->normalizer->normalizePath($destination);
608
609        try {
610            $sourceSymlinkDetails = $this->getSymlinkDetails($source);
611        } catch (RuntimeException $exception) {
612            // Source file not found.
613            throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
614        }
615        $isSourceSymlinked = !empty($sourceSymlinkDetails);
616        if ($isSourceSymlinked) {
617            switch ($this->linkHandling) {
618                case LocalFilesystemAdapter::SKIP_LINKS:
619                    return;
620                case LocalFilesystemAdapter::DISALLOW_LINKS:
621                    throw SymbolicLinkEncountered::atLocation($source);
622                case SymlinkProtectFilesystemAdapter::WARN_LINKS:
623                case SymlinkProtectFilesystemAdapter::THROW_LINKS:
624                    // We do not throw here because THROW_LINKS only applies for write operations – it allows reading.
625                default:
626                    break;
627            }
628        }
629
630        $existingDestinationParentDir = $destination;
631        do {
632            $existingDestinationParentDir = dirname($existingDestinationParentDir);
633        } while (!$this->directoryExists($existingDestinationParentDir));
634
635        $destinationSymlinkDetails = $this->getSymlinkDetails($existingDestinationParentDir);
636        $isDestinationSymlinked = !empty($destinationSymlinkDetails);
637
638        if (!$isDestinationSymlinked) {
639            parent::copy($source, $destination, $config);
640            return;
641        }
642
643        switch ($this->linkHandling) {
644            case LocalFilesystemAdapter::SKIP_LINKS:
645                return;
646            case LocalFilesystemAdapter::DISALLOW_LINKS:
647                throw SymbolicLinkEncountered::atLocation($destination);
648            case SymlinkProtectFilesystemAdapter::WARN_LINKS:
649                $this->logger->warning(
650                    "Copying file to destination {destination} under symlink {flysystemPath} realpath {targetAbsoluteFilesystemPath}",
651                    array_merge(
652                        [
653                            'destination' => $destination,
654                            'operation' => __METHOD__
655                        ],
656                        (array) $destinationSymlinkDetails
657                    )
658                );
659                parent::copy($source, $destination, $config);
660                return;
661            case SymlinkProtectFilesystemAdapter::THROW_LINKS:
662            default:
663                throw UnableToCopyFile::fromLocationTo($source, $destination);
664        }
665    }
666
667    public function fileExists(string $path): bool
668    {
669        $path = $this->normalizer->normalizePath($path);
670
671        // We run this so first encounter with a symlink is recorded.
672        try {
673            $this->getSymlinkDetails($path);
674        } finally {
675            return parent::fileExists($path);
676        }
677    }
678
679    /**
680     * @throws FilesystemException|UnableToReadFile
681     */
682    public function read(string $path): string
683    {
684        $path = $this->normalizer->normalizePath($path);
685
686        try {
687            // We run this so first encounter with a symlink is recorded.
688            $this->getSymlinkDetails($path);
689        } finally {
690            return parent::read($path);
691        }
692    }
693
694    /**
695     * @throws FilesystemException|UnableToReadFile
696     */
697    public function readStream(string $path)
698    {
699        $path = $this->normalizer->normalizePath($path);
700
701        try {
702            // We run this so first encounter with a symlink is recorded.
703            $this->getSymlinkDetails($path);
704        } finally {
705            return parent::readStream($path);
706        }
707    }
708
709    public function visibility(string $path): FileAttributes
710    {
711        $path = $this->normalizer->normalizePath($path);
712
713        try {
714            // We run this so first encounter with a symlink is recorded.
715            $this->getSymlinkDetails($path);
716        } finally {
717            return parent::visibility($path);
718        }
719    }
720
721    public function mimeType(string $path): FileAttributes
722    {
723        $path = $this->normalizer->normalizePath($path);
724
725        try {
726            // We run this so first encounter with a symlink is recorded.
727            $this->getSymlinkDetails($path);
728        } finally {
729            return parent::mimeType($path);
730        }
731    }
732
733    public function lastModified(string $path): FileAttributes
734    {
735        $path = $this->normalizer->normalizePath($path);
736
737        try {
738            // We run this so first encounter with a symlink is recorded.
739            $this->getSymlinkDetails($path);
740        } finally {
741            return parent::lastModified($path);
742        }
743    }
744
745    public function fileSize(string $path): FileAttributes
746    {
747        $path = $this->normalizer->normalizePath($path);
748
749        try {
750            // We run this so first encounter with a symlink is recorded.
751            $this->getSymlinkDetails($path);
752        } finally {
753            return parent::fileSize($path);
754        }
755    }
756
757    /**
758     * This is the only method in the parent class which uses {@see LocalFilesystemAdapter::$linkHandling} – we don't
759     * use that because its results will not include files/directories that are symlinks.
760     *
761     * @see LocalFilesystemAdapter::listContents()
762     *
763     * @param string $path
764     * @param bool $deep
765     *
766     * @return iterable
767     * @throws FilesystemException
768     */
769    public function listContents(string $path, bool $deep): iterable
770    {
771        $path = $this->normalizer->normalizePath($path);
772        $location = $this->pathPrefixer->prefixPath($path);
773
774        if (! is_dir($location)) {
775            return;
776        }
777
778        /** @var SplFileInfo[] $iterator */
779        $iterator = $deep ? $this->parentListDirectoryRecursively($location) : $this->parentListDirectory($location);
780
781        foreach ($iterator as $fileInfo) {
782            $symlinkDetails = $this->getSymlinkDetails($fileInfo->getPathname());
783            $isSymlinked = !empty($symlinkDetails);
784
785            if ($isSymlinked) {
786                switch ($this->linkHandling) {
787                    case LocalFilesystemAdapter::SKIP_LINKS:
788                        continue 2;
789                    case LocalFilesystemAdapter::DISALLOW_LINKS:
790                        throw SymbolicLinkEncountered::atLocation($fileInfo->getPathname());
791                    case SymlinkProtectFilesystemAdapter::WARN_LINKS:
792                    case SymlinkProtectFilesystemAdapter::THROW_LINKS:
793                    default:
794                        // We are not warning or throwing here because we are just reading.
795                        break;
796                }
797            }
798
799            $path = $this->pathPrefixer->stripPrefix($fileInfo->getPathname());
800            $lastModified = $fileInfo->getMTime();
801            $isDirectory = $fileInfo->isDir();
802            $permissions = octdec(substr(sprintf('%o', $fileInfo->getPerms()), -4));
803            $visibility = $isDirectory ? $this->visibility->inverseForDirectory($permissions) : $this->visibility->inverseForFile($permissions);
804
805            yield $isDirectory ? new DirectoryAttributes($path, $visibility, $lastModified) : new FileAttributes(
806                str_replace('\\', '/', $path),
807                $fileInfo->getSize(),
808                $visibility,
809                $lastModified
810            );
811        }
812    }
813
814    /**
815     * (fn () => $this->protectedProperty)-›call ($object)
816     *
817     * Copied, unchanged, from parent due to private protection.
818     * @used-by ::listContents
819     * @see LocalFilesystemAdapter::listDirectoryRecursively()
820     */
821    private function parentListDirectoryRecursively(
822        string $path,
823        int $mode = RecursiveIteratorIterator::SELF_FIRST
824    ): Generator {
825        yield from new RecursiveIteratorIterator(
826            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
827            $mode
828        );
829    }
830
831    /**
832     * Copied, unchanged, from parent due to private protection.
833     * @used-by ::listContents
834     * @see LocalFilesystemAdapter::listDirectory()
835     */
836    private function parentListDirectory(string $location): Generator
837    {
838        $iterator = new DirectoryIterator($location);
839
840        foreach ($iterator as $item) {
841            if ($item->isDot()) {
842                continue;
843            }
844
845            yield $item;
846        }
847    }
848}