Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.26% covered (danger)
10.26%
31 / 302
4.17% covered (danger)
4.17%
1 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
SymlinkProtectFilesystemAdapter
10.26% covered (danger)
10.26%
31 / 302
4.17% covered (danger)
4.17%
1 / 24
2240.81
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
 getParentSymlink
67.86% covered (warning)
67.86%
19 / 28
0.00% covered (danger)
0.00%
0 / 1
10.13
 isSymlinked
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recordSymlink
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 isWindowsOS
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNormalizer
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 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 writeStream
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 setVisibility
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 delete
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
12
 deleteDirectory
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 createDirectory
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 move
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 copy
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 fileExists
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
2.69
 read
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 readStream
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 visibility
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 mimeType
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 lastModified
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 fileSize
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 listContents
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
2.86
1<?php
2/**
3 * The idea is to prevent write/delete operations to symlinked files.
4 *
5 * This class proxies filesystem operations to another Flysystem adapter, but checks if the path is
6 * symlinked and prevents write/delete operations to files/directories inside a symlinked directory.
7 * Unlinks symlinks if delete is called on them.
8 *
9 * Read operations act normally, write operations log warnings and errors.
10 *
11 * Outcome of trying to delete a file inside a symlink:
12 * * logs an error (FlySystem FileSystemAdapter probably throws an exception)
13 * * prevents future access to the file (as though it really were deleted)
14 *
15 * Outcome of trying to write a file inside a symlink:
16 * * logs a warning (FlySystem FileSystemAdapter probably throws an exception)
17 *
18 * Outcome of trying to delete a symlink:
19 * * logs a notice (FlySystem FileSystemAdapter probably throws an exception)
20 * * unlinks the target (what we want to do)
21 * * we must be careful to not delete the target of the symlink
22 *
23 * Outcome of read operation on symlinked files:
24 * * Debug log every time a symlinked file is read
25 *
26 * Outcome of write/modify operation on non-symlinked files:
27 * * nothing
28 *
29 * Info log the first time each symlink is seen
30 *
31 * Your implementation of LoggerInterface can decide what to do with the log messages, e.g.
32 * throw an exception on error, or just log them with an instruction on how to remedy the issue.
33 *
34 * TODO: Should this just extend LocalFilesystemAdapter since it only applies to local filesystems?
35 *
36 * @see \League\Flysystem\SymbolicLinkEncountered
37 * @see \League\Flysystem\Local\LocalFilesystemAdapter::SKIP_LINKS
38 * @see \League\Flysystem\Local\LocalFilesystemAdapter::DISALLOW_LINKS
39 * @see \League\Flysystem\Local\LocalFilesystemAdapter::$linkHandling
40 * @see \League\Flysystem\Local\LocalFilesystemAdapter::listContents()
41 */
42
43namespace BrianHenryIE\Strauss\Helpers;
44
45use League\Flysystem\Config;
46use League\Flysystem\FileAttributes;
47use League\Flysystem\FilesystemAdapter;
48use League\Flysystem\FilesystemException;
49use League\Flysystem\Local\LocalFilesystemAdapter;
50use League\Flysystem\PathNormalizer;
51use League\Flysystem\UnixVisibility\VisibilityConverter;
52use League\MimeTypeDetection\MimeTypeDetector;
53use Psr\Log\LoggerAwareTrait;
54use Psr\Log\LoggerInterface;
55use Psr\Log\NullLogger;
56
57class SymlinkProtectFilesystemAdapter extends LocalFilesystemAdapter implements FlysystemBackCompatTraitInterface
58{
59    use FlysystemBackCompatTrait;
60    use LoggerAwareTrait;
61
62    protected PathNormalizer $normalizer;
63
64    /**
65     * Converts flysystem relative paths to filesystem absolute paths.
66     */
67    protected PathPrefixer $pathPrefixer;
68
69    /**
70     * Record of discovered symlink paths
71     * * allows faster lookup in future
72     * * provides list of symlinked paths for "did we encounter a symlink" checks
73     *
74     * @var array<string, string> Array of flysystem paths : realpath.
75     */
76    protected array $symlinkRealPaths = [];
77
78    /**
79     * Record of non-symlinked paths to avoid running is_link repeatedly.
80     *
81     * I.e. no need to `/check/every/level/of/this/when` when partial path has been checked before.
82     */
83    protected array $nonSymlinkPaths = [];
84
85    /**
86     * Record of all files already checked.
87     *
88     * @var array<string, string|null> Array of flysystem paths : parent path which is a symlink, or null.
89     */
90    protected array $parentSymlinkPathCache = [];
91
92    /**
93     * TODO: If a symlinked file is "deleted", keep a record of it and prevent any future access to it.
94     * TODO: If a symlinked directory is "deleted" forbid access to any files inside it.
95     *
96     * @var array<string, string> Array of flysystem relative paths : flysystem relative paths.
97     */
98    protected array $deletedPaths = [];
99
100    public function __construct(
101        $location,
102        ?PathNormalizer $pathNormalizer = null,
103        ?PathPrefixer $pathPrefixer = null,
104        ?LoggerInterface $logger = null,
105        ?VisibilityConverter $visibility = null,
106        int $writeFlags = LOCK_EX,
107        int $linkHandling = LocalFilesystemAdapter::SKIP_LINKS,
108        ?MimeTypeDetector $mimeTypeDetector = null
109    ) {
110        parent::__construct($location, $visibility, $writeFlags, $linkHandling, $mimeTypeDetector);
111
112        $this->setLogger($logger ?? new NullLogger());
113
114        $this->pathPrefixer = $pathPrefixer ?? new PathPrefixer($location, DIRECTORY_SEPARATOR);
115        $this->normalizer = $pathNormalizer ?? Filesystem::makePathNormalizer($location);
116    }
117
118    /**
119     * Check is a file within a symlinked directory.
120     *
121     * Realpath expects a true file to exist on the filesystem.
122     * I.e. using flysystem with a root path which is relative to the true filesystem, will never work.
123     *
124     * @see \SplFileInfo::isLink()
125     *
126     * @see https://github.com/php/php-src/issues/12118
127     *
128     * @return ?string The filesystem path to the symlink, or null if not a symlink.
129     */
130    protected function getParentSymlink(string $path): ?string
131    {
132        if (isset($this->parentSymlinkPathCache[$path])) {
133            return $this->parentSymlinkPathCache[$path];
134        }
135
136        // Any parent directory already known to be inside a symlink shows this file is inside that same symlink.
137        foreach ($this->symlinkRealPaths as $flysystemPath => $realPath) {
138            if (str_starts_with($path, $flysystemPath)) {
139//                $target = $this->normalizer->normalizePath($symlinkPath . str_replace($flysystemPath, '', $path));
140                $this->parentSymlinkPathCache[$path] = $flysystemPath;
141                return $flysystemPath;
142            }
143        }
144
145        $absolutePath = $this->pathPrefixer->prefixPath($path);
146
147        $prefixParts = explode('/', $path);
148        $checkedPaths = [];
149        do {
150            $partsPath = implode('/', $prefixParts);
151            $absoluteParentDir = $this->pathPrefixer->prefixPath($partsPath);
152            if (isset($this->nonSymlinkPaths[$partsPath])) {
153                return null;
154            }
155            if (is_link($absoluteParentDir)) {
156                $this->recordSymlink($partsPath);
157                $this->nonSymlinkPaths = array_merge($this->nonSymlinkPaths, $checkedPaths);
158                return $partsPath;
159            } else {
160                $checkedPaths[$partsPath] = $absoluteParentDir;
161            }
162            array_pop($prefixParts);
163        } while (count($prefixParts) > 0);
164
165        $realpath = realpath($absolutePath);
166
167        /**
168         * If realpath() returns false, the file may be in an in-memory filesystem.
169         * Or maybe the file really does not exist.
170         *
171         * @see https://github.com/php/php-src/issues/12118
172         */
173        if ($realpath === false
174            || $absolutePath === $realpath) {
175            $this->nonSymlinkPaths = array_merge($this->nonSymlinkPaths, $checkedPaths);
176
177            $this->parentSymlinkPathCache[$path] = null;
178
179            return null;
180        }
181
182        $this->recordSymlink($path, $realpath);
183
184        return $absolutePath;
185    }
186
187    /**
188     * Given the path to a file or folder, is it inside a symlinked directory?
189     */
190    public function isSymlinked(string $path): bool
191    {
192        $path = $this->normalizer->normalizePath($path);
193
194        return (bool) $this->getParentSymlink($path);
195    }
196
197    protected function recordSymlink(string $path, ?string $realpath = null): void
198    {
199        $symlinkTarget = $realpath ?? realpath($path);
200
201        if (isset($this->symlinkRealPaths[$path])) {
202            return;
203        }
204
205        $this->parentSymlinkPathCache[$path] = $path;
206        $this->symlinkRealPaths[$path] = $realpath;
207
208        $this->logger->info(
209            "New symlink found at {$path} target {$symlinkTarget}.",
210            [
211                'source' => $path,
212                'target' => $symlinkTarget,
213            ]
214        );
215    }
216
217    public function getSymlinks(): array
218    {
219        return $this->symlinkRealPaths;
220    }
221
222    /**
223     * Deleting a symlink is different on Windows and Linux.
224     *
225     * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory.
226     * "On windows, take care that `is_link()` returns false for Junctions."
227     *
228     * @see https://www.php.net/manual/en/function.is-link.php#113263
229     * @see https://stackoverflow.com/a/18262809/336146
230     * @throws FilesystemException
231     */
232    protected function removeSymlink(string $path): bool
233    {
234        $fullPath = $this->pathPrefixer->prefixPath($path);
235
236        $this->logger->notice('Deleting symlink at ' . $fullPath . ' (points to ' . realpath($fullPath) . ')');
237
238        if ($this->isWindowsOS()) {
239            rmdir($fullPath);
240        } else {
241            unlink($fullPath);
242        }
243
244        return !method_exists($this, 'directoryExists')
245            ? !$this->fileExists($path)
246            : !$this->fileExists($path) && !$this->directoryExists($path);
247    }
248
249
250    /**
251     * Check are we running on Windows, whose symlink behaviour differs.
252     *
253     * TODO: Consider using `PHP_OS_FAMILY` instead.
254     *
255     * @see https://www.php.net/manual/en/reserved.constants.php#constant.php-os
256     */
257    protected function isWindowsOS(): bool
258    {
259        return false !== strpos('WIN', constant('PHP_OS'));
260    }
261
262    /**
263     * @see FlysystemBackCompatTrait::directoryExists()
264     */
265    public function getNormalizer(): PathNormalizer
266    {
267        return $this->normalizer;
268    }
269
270    /**
271     * @see FilesystemAdapter::write()
272     */
273    public function write(string $path, string $contents, Config $config): void
274    {
275        $path = $this->normalizer->normalizePath($path);
276
277        $symlink = $this->getParentSymlink($path);
278
279        if (!$symlink) {
280            $this->logger->debug("Writing non-symlinked file at {$path}", [
281                'method' => __METHOD__,
282                'args' => func_get_args()
283            ]);
284            parent::write($path, $contents, $config);
285            return;
286        }
287
288        $this->logger->warning(
289            'File is/is under a symlinked path.',
290            [
291                'symlink' => $symlink,
292                'method' => __METHOD__,
293                'args' =>func_get_args()
294            ]
295        );
296    }
297
298    /**
299     * @see FilesystemAdapter::writeStream()
300     */
301    public function writeStream(string $path, $contents, Config $config): void
302    {
303        $path = $this->normalizer->normalizePath($path);
304
305        $symlink = $this->getParentSymlink($path);
306
307        if (!$symlink) {
308            $this->logger->debug("Writing stream for non-symlinked file at {$path}", [
309                'method' => __METHOD__,
310                'args' => func_get_args()
311            ]);
312            parent::writeStream($path, $contents, $config);
313            return;
314        }
315
316        $this->logger->warning(
317            'File is/is under a symlinked path.',
318            [
319                'symlink' => $symlink,
320                'method' => __METHOD__,
321                'args' =>func_get_args()
322            ]
323        );
324    }
325
326    public function setVisibility(string $path, string $visibility): void
327    {
328        $path = $this->normalizer->normalizePath($path);
329
330        $symlink = $this->getParentSymlink($path);
331
332        if (!$symlink) {
333            $this->logger->debug("Setting visibility for non-symlinked file at {$path}", [
334                'method' => __METHOD__,
335                'args' => func_get_args()
336            ]);
337            parent::setVisibility($path, $visibility);
338            return;
339        }
340
341        $this->logger->warning(
342            'File is/is under a symlinked path.',
343            [
344                'symlink' => $symlink,
345                'method' => __METHOD__,
346                'args' =>func_get_args()
347            ]
348        );
349    }
350
351    public function delete(string $path): void
352    {
353        $path = $this->normalizer->normalizePath($path);
354
355        $symlink = $this->getParentSymlink($path);
356
357        if (!$symlink) {
358            $this->logger->debug("Deleting non-symlinked file at {$path}", [
359                'method' => __METHOD__,
360                'args' => func_get_args()
361            ]);
362            parent::delete($path);
363            return;
364        }
365
366        $this->deletedPaths[$path] = $symlink;
367
368        $fullPath = $this->pathPrefixer->prefixPath($path);
369        if ($symlink === $fullPath) {
370            $didRemove = $this->removeSymlink($path);
371            $this->logger->notice(
372                'File is a symlinked path, removing.',
373                [
374                    'didRemove' => $didRemove,
375                    'symlink' => $symlink,
376                    'method' => __METHOD__,
377                    'args' =>func_get_args()
378                ]
379            );
380            return;
381        }
382
383        $this->logger->error(
384            'File is under a symlinked path.',
385            [
386                'symlink' => $symlink,
387                'method' => __METHOD__,
388                'args' =>func_get_args()
389            ]
390        );
391    }
392
393    public function deleteDirectory(string $path): void
394    {
395        $path = $this->normalizer->normalizePath($path);
396
397        // If $path is under a symlink, get that path (not the target of the symlink).
398        $symlinkPath = $this->getParentSymlink($path);
399
400        if (!$symlinkPath) {
401            $this->logger->debug("Deleting non-symlinked directory at {$path}", [
402                'method' => __METHOD__,
403                'args' => func_get_args()
404            ]);
405            parent::deleteDirectory($path);
406            return;
407        }
408
409        $this->deletedPaths[$path] = $symlinkPath;
410
411        if ($path === $symlinkPath) {
412            $didRemove = $this->removeSymlink($path);
413            $this->logger->notice(
414                'Directory is a symlink, removing.',
415                [
416                    'didRemove' => $didRemove,
417                    'symlink' => $symlinkPath,
418                    'method' => __METHOD__,
419                    'args' =>func_get_args()
420                ]
421            );
422            return;
423        }
424
425        $this->logger->error(
426            'Directory is under a symlinked path.',
427            [
428                'symlink' => $symlinkPath,
429                'method' => __METHOD__,
430                'args' =>func_get_args()
431            ]
432        );
433    }
434
435    public function createDirectory(string $path, Config $config): void
436    {
437        $path = $this->normalizer->normalizePath($path);
438
439        $symlink = $this->getParentSymlink($path);
440
441        if (!$symlink) {
442            $this->logger->debug("Creating directory at non-symlinked path {$path}", [
443                'method' => __METHOD__,
444                'args' => func_get_args()
445            ]);
446            parent::createDirectory($path, $config);
447            return;
448        }
449
450        $this->logger->warning(
451            'Attempted to create directory under a symlinked path.',
452            [
453                'symlink' => $symlink,
454                'method' => __METHOD__,
455                'args' =>func_get_args()
456            ]
457        );
458    }
459
460    public function move(string $source, string $destination, Config $config): void
461    {
462        $source = $this->normalizer->normalizePath($source);
463        $destination = $this->normalizer->normalizePath($destination);
464
465        $sourceSymlink = $this->getParentSymlink($source);
466        $destinationSymlink = $this->getParentSymlink($destination);
467
468        if (!$sourceSymlink && !$destinationSymlink) {
469            $this->logger->debug("Creating directory at non-symlinked path {$destination}", [
470                'method' => __METHOD__,
471                'args' => func_get_args()
472            ]);
473            parent::move($source, $destination, $config);
474            return;
475        }
476
477        $this->logger->warning(
478            'Attempted to move file/directory under a symlinked path.',
479            [
480                'symlink' => $sourceSymlink || $destinationSymlink,
481                'method' => __METHOD__,
482                'args' =>func_get_args()
483            ]
484        );
485    }
486
487    public function copy(string $source, string $destination, Config $config): void
488    {
489        $source = $this->normalizer->normalizePath($source);
490        $destination = $this->normalizer->normalizePath($destination);
491
492        $symlink = $this->getParentSymlink($destination);
493
494        if (!$symlink) {
495            $this->logger->debug("Copying file/dir at non-symlinked path {$destination}", [
496                'method' => __METHOD__,
497                'args' => func_get_args()
498            ]);
499            parent::copy($source, $destination, $config);
500            return;
501        }
502
503        $this->logger->warning(
504            'Attempted to move file/directory under a symlinked path.',
505            [
506                'symlink' => $symlink,
507                'method' => __METHOD__,
508                'args' => func_get_args()
509            ]
510        );
511    }
512
513    public function fileExists(string $path): bool
514    {
515        $path = $this->normalizer->normalizePath($path);
516        $symlink = $this->getParentSymlink($path);
517        if ($symlink) {
518            $this->logger->debug("FileExists {$path} inside symlink {$symlink}", [
519                'source' => $path,
520                'method' => __METHOD__,
521                'args' => func_get_args()
522            ]);
523        }
524
525        return parent::fileExists($path);
526    }
527
528    public function read(string $path): string
529    {
530        $path = $this->normalizer->normalizePath($path);
531        $symlink = $this->getParentSymlink($path);
532        if ($symlink) {
533            $this->logger->debug("Reading symlinked file at {$path} to target {$symlink}", [
534                'source' => $path,
535                'target' => $symlink,
536                'method' => __METHOD__,
537                'args' => func_get_args()
538            ]);
539        }
540
541        return parent::read($path);
542    }
543
544    public function readStream(string $path)
545    {
546        $path = $this->normalizer->normalizePath($path);
547        $symlink = $this->getParentSymlink($path);
548        if ($symlink) {
549            $this->logger->debug(__FUNCTION__ . " symlinked {$path} to {$symlink}", [
550                'source' => $path,
551                'target' => $symlink,
552                'method' => __METHOD__,
553                'args' => func_get_args()
554            ]);
555        }
556
557        return parent::readStream($path);
558    }
559
560    public function visibility(string $path): FileAttributes
561    {
562        $path = $this->normalizer->normalizePath($path);
563        $symlink = $this->getParentSymlink($path);
564        if ($symlink) {
565            $this->logger->debug(__FUNCTION__ . " symlinked {$path} to {$symlink}", [
566                'source' => $path,
567                'target' => $symlink,
568                'method' => __METHOD__,
569                'args' => func_get_args()
570            ]);
571        }
572
573        return parent::visibility($path);
574    }
575
576    public function mimeType(string $path): FileAttributes
577    {
578        $path = $this->normalizer->normalizePath($path);
579        $symlink = $this->getParentSymlink($path);
580        if ($symlink) {
581            $this->logger->debug(__FUNCTION__ . " symlinked {$path} to {$symlink}", [
582                'source' => $path,
583                'target' => $symlink,
584                'method' => __METHOD__,
585                'args' => func_get_args()
586            ]);
587        }
588
589        return parent::mimeType($path);
590    }
591
592    public function lastModified(string $path): FileAttributes
593    {
594        $path = $this->normalizer->normalizePath($path);
595        $symlink = $this->getParentSymlink($path);
596        if ($symlink) {
597            $this->logger->debug(__FUNCTION__ . " symlinked {$path} to {$symlink}", [
598                'source' => $path,
599                'target' => $symlink,
600                'method' => __METHOD__,
601                'args' => func_get_args()
602            ]);
603        }
604
605        return parent::lastModified($path);
606    }
607
608    public function fileSize(string $path): FileAttributes
609    {
610        $path = $this->normalizer->normalizePath($path);
611        $symlink = $this->getParentSymlink($path);
612        if ($symlink) {
613            $this->logger->debug(__FUNCTION__ . " symlinked {$path} to {$symlink}", [
614                'source' => $path,
615                'target' => $symlink,
616                'method' => __METHOD__,
617                'args' => func_get_args()
618            ]);
619        }
620
621        return parent::fileSize($path);
622    }
623
624    public function listContents(string $path, bool $deep): iterable
625    {
626        $path = $this->normalizer->normalizePath($path);
627        $symlink = $this->getParentSymlink($path);
628        if ($symlink) {
629            $this->logger->debug(__FUNCTION__ . " {$path} is under symlink {$symlink}", [
630                'source' => $path,
631                'target' => $symlink,
632                'method' => __METHOD__,
633                'args' => func_get_args()
634            ]);
635        }
636
637        return parent::listContents($path, $deep);
638    }
639}