Completed
Pull Request — 1.0 (#83)
by Bernhard
21:18
created

FilesystemRepository::removeResource()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4

Importance

Changes 2
Bugs 0 Features 1
Metric Value
c 2
b 0
f 1
dl 0
loc 22
ccs 10
cts 10
cp 1
rs 8.9197
cc 4
eloc 10
nc 3
nop 2
crap 4
1
<?php
2
3
/*
4
 * This file is part of the puli/repository package.
5
 *
6
 * (c) Bernhard Schussek <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Puli\Repository;
13
14
use Iterator;
15
use Puli\Repository\Api\ChangeStream\ChangeStream;
16
use Puli\Repository\Api\Resource\BodyResource;
17
use Puli\Repository\Api\Resource\FilesystemResource;
18
use Puli\Repository\Api\Resource\PuliResource;
19
use Puli\Repository\Api\ResourceCollection;
20
use Puli\Repository\Api\ResourceNotFoundException;
21
use Puli\Repository\Api\UnsupportedOperationException;
22
use Puli\Repository\Api\UnsupportedResourceException;
23
use Puli\Repository\Resource\Collection\FilesystemResourceCollection;
24
use Puli\Repository\Resource\DirectoryResource;
25
use Puli\Repository\Resource\FileResource;
26
use Puli\Repository\Resource\LinkResource;
27
use Symfony\Component\Filesystem\Exception\IOException;
28
use Symfony\Component\Filesystem\Filesystem;
29
use Webmozart\Assert\Assert;
30
use Webmozart\Glob\Iterator\GlobIterator;
31
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
32
use Webmozart\PathUtil\Path;
33
34
/**
35
 * A repository reading from the file system.
36
 *
37
 * Resources can be read using their absolute file system paths:
38
 *
39
 * ```php
40
 * use Puli\Repository\FilesystemRepository;
41
 *
42
 * $repo = new FilesystemRepository();
43
 * $resource = $repo->get('/home/puli/.gitconfig');
44
 * ```
45
 *
46
 * The returned resources implement {@link FilesystemResource}.
47
 *
48
 * Optionally, a root directory can be passed to the constructor. Then all paths
49
 * will be read relative to that directory:
50
 *
51
 * ```php
52
 * $repo = new FilesystemRepository('/home/puli');
53
 * $resource = $repo->get('/.gitconfig');
54
 * ```
55
 *
56
 * While "." and ".." segments are supported, files outside the root directory
57
 * cannot be read. Any leading ".." segments will simply be stripped off.
58
 *
59
 * @since  1.0
60
 *
61
 * @author Bernhard Schussek <[email protected]>
62
 */
63
class FilesystemRepository extends AbstractEditableRepository
64
{
65
    /**
66
     * @var bool|null
67
     */
68
    private static $symlinkSupported;
69
70
    /**
71
     * @var string
72
     */
73
    private $baseDir;
74
75
    /**
76
     * @var int
77
     */
78
    private $baseDirLength;
79
80
    /**
81
     * @var bool
82
     */
83
    private $symlink;
84
85
    /**
86
     * @var bool
87
     */
88
    private $relative;
89
90
    /**
91
     * @var Filesystem
92
     */
93
    private $filesystem;
94
95
    /**
96
     * Returns whether symlinks are supported in the local environment.
97
     *
98
     * @return bool Returns `true` if symlinks are supported.
99
     */
100 303
    public static function isSymlinkSupported()
101
    {
102 303
        if (null === self::$symlinkSupported) {
103
            // http://php.net/manual/en/function.symlink.php
104
            // Symlinks are only supported on Windows Vista, Server 2008 or
105
            // greater on PHP 5.3+
106 1
            if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
107
                self::$symlinkSupported = PHP_WINDOWS_VERSION_MAJOR >= 6;
108
            } else {
109 1
                self::$symlinkSupported = true;
110
            }
111
        }
112
113 303
        return self::$symlinkSupported;
114
    }
115
116
    /**
117
     * Creates a new repository.
118
     *
119
     * @param string            $baseDir      The base directory of the repository on the file
120
     *                                        system.
121
     * @param bool              $symlink      Whether to use symbolic links for added files. If
122
     *                                        symbolic links are not supported on the current
123
     *                                        system, the repository will create hard copies
124
     *                                        instead.
125
     * @param bool              $relative     Whether to create relative symbolic links. If
126
     *                                        relative links are not supported on the current
127
     *                                        system, the repository will create absolute links
128
     *                                        instead.
129
     * @param ChangeStream|null $changeStream If provided, the repository will log
130
     *                                        resources changes in this change stream.
131
     */
132 392
    public function __construct($baseDir = '/', $symlink = true, $relative = true, ChangeStream $changeStream = null)
133
    {
134 392
        parent::__construct($changeStream);
135
136 392
        Assert::directory($baseDir);
137 392
        Assert::boolean($symlink);
138
139 392
        $this->baseDir = rtrim(Path::canonicalize($baseDir), '/');
140 392
        $this->baseDirLength = strlen($baseDir);
141 392
        $this->symlink = $symlink && self::isSymlinkSupported();
142 392
        $this->relative = $this->symlink && $relative;
143 392
        $this->filesystem = new Filesystem();
144 392
    }
145
146
    /**
147
     * {@inheritdoc}
148
     */
149 167 View Code Duplication
    public function get($path)
150
    {
151 167
        $path = $this->sanitizePath($path);
152 155
        $filesystemPath = $this->baseDir.$path;
153
154 155
        if (!file_exists($filesystemPath)) {
155 8
            throw ResourceNotFoundException::forPath($path);
156
        }
157
158 147
        return $this->createResource($filesystemPath, $path);
159
    }
160
161
    /**
162
     * {@inheritdoc}
163
     */
164 41
    public function find($query, $language = 'glob')
165
    {
166 41
        return $this->iteratorToCollection($this->getGlobIterator($query, $language));
167
    }
168
169
    /**
170
     * {@inheritdoc}
171
     */
172 61
    public function contains($query, $language = 'glob')
173
    {
174 61
        $iterator = $this->getGlobIterator($query, $language);
175 48
        $iterator->rewind();
176
177 48
        return $iterator->valid();
178
    }
179
180
    /**
181
     * {@inheritdoc}
182
     */
183 28
    public function hasChildren($path)
184
    {
185 28
        $filesystemPath = $this->getFilesystemPath($path);
186
187 12
        if (!is_dir($filesystemPath)) {
188 4
            return false;
189
        }
190
191 12
        $iterator = $this->getDirectoryIterator($filesystemPath);
192 12
        $iterator->rewind();
193
194 12
        return $iterator->valid();
195
    }
196
197
    /**
198
     * {@inheritdoc}
199
     */
200 42
    public function listChildren($path)
201
    {
202 42
        $filesystemPath = $this->getFilesystemPath($path);
203
204 26
        if (!is_dir($filesystemPath)) {
205 4
            return new FilesystemResourceCollection();
206
        }
207
208 22
        return $this->iteratorToCollection($this->getDirectoryIterator($filesystemPath));
209
    }
210
211
    /**
212
     * {@inheritdoc}
213
     */
214 357
    public function add($path, $resource)
215
    {
216 357
        $path = $this->sanitizePath($path);
217
218 345 View Code Duplication
        if ($resource instanceof ResourceCollection) {
219 4
            $this->ensureDirectoryExists($path);
220 4
            foreach ($resource as $child) {
221 4
                $this->addResource($path.'/'.$child->getName(), $child);
222
            }
223
224 4
            return;
225
        }
226
227 341
        if ($resource instanceof PuliResource) {
228 337
            $this->ensureDirectoryExists(Path::getDirectory($path));
229 337
            $this->addResource($path, $resource);
230
231 332
            return;
232
        }
233
234 4
        throw new UnsupportedResourceException(sprintf(
235 4
            'The passed resource must be a PuliResource or ResourceCollection. Got: %s',
236 4
            is_object($resource) ? get_class($resource) : gettype($resource)
237
        ));
238
    }
239
240
    /**
241
     * {@inheritdoc}
242
     */
243 61
    public function remove($query, $language = 'glob')
244
    {
245 61
        $iterator = $this->getGlobIterator($query, $language);
246 48
        $removed = 0;
247
248 48
        Assert::notEmpty(trim($query, '/'), 'The root directory cannot be removed.');
249
250
        // There's some problem with concurrent deletions at the moment
251 40
        foreach (iterator_to_array($iterator) as $filesystemPath) {
252 40
            $this->removeResource($filesystemPath, $removed);
253
        }
254
255 40
        return $removed;
256
    }
257
258
    /**
259
     * {@inheritdoc}
260
     */
261 12
    public function clear()
262
    {
263 12
        $iterator = $this->getDirectoryIterator($this->baseDir);
264 12
        $removed = 0;
265
266
        // Batch-delete all versions
267 12
        $this->clearVersions();
268
269 12
        foreach ($iterator as $filesystemPath) {
270 8
            $this->removeResource($filesystemPath, $removed);
271
        }
272
273 12
        $this->storeVersion($this->get('/'));
274
275 12
        return $removed;
276
    }
277
278 341
    private function ensureDirectoryExists($path)
279
    {
280 341
        $filesystemPath = $this->baseDir.$path;
281
282 341
        if (is_file($filesystemPath)) {
283 1
            throw new UnsupportedOperationException(sprintf(
284
                'Instances of BodyResource do not support child resources in '.
285 1
                'FilesystemRepository. Tried to add a child to %s.',
286
                $filesystemPath
287
            ));
288
        }
289
290 341
        if (!is_dir($filesystemPath)) {
291 112
            mkdir($filesystemPath, 0777, true);
292
        }
293 341
    }
294
295 341
    private function addResource($path, PuliResource $resource, $checkParentsForSymlinks = true)
296
    {
297 341
        $pathInBaseDir = $this->baseDir.$path;
298 341
        $hasChildren = $resource->hasChildren();
299 341
        $hasBody = $resource instanceof BodyResource;
300
301 341
        if ($hasChildren && $hasBody) {
302 1
            throw new UnsupportedResourceException(sprintf(
303
                'Instances of BodyResource do not support child resources in '.
304
                'FilesystemRepository. Tried to add a BodyResource with '.
305 1
                'children at %s.',
306
                $path
307
            ));
308
        }
309
310 340
        $resource = clone $resource;
311 340
        $resource->attachTo($this, $path);
312
313 340
        if ($this->symlink && $checkParentsForSymlinks) {
314 182
            $this->replaceParentSymlinksByCopies($path);
315
        }
316
317 340
        if ($resource instanceof FilesystemResource) {
318 33
            if ($this->symlink) {
319 30
                $this->symlinkMirror($resource->getFilesystemPath(), $pathInBaseDir);
320 3
            } elseif ($hasBody) {
321 2
                $this->filesystem->copy($resource->getFilesystemPath(), $pathInBaseDir);
322
            } else {
323 1
                $this->filesystem->mirror($resource->getFilesystemPath(), $pathInBaseDir);
324
            }
325
326 33
            $this->storeVersion($resource);
327
328 33
            return;
329
        }
330
331 315
        if ($resource instanceof LinkResource) {
332 8
            if (!$this->symlink) {
333 4
                throw new UnsupportedResourceException(sprintf(
334
                    'LinkResource requires support of symbolic links in FilesystemRepository. '.
335 4
                    'Tried to add a LinkResource at %s.',
336
                    $path
337
                ));
338
            }
339
340 4
            $this->filesystem->symlink($this->baseDir.$resource->getTargetPath(), $pathInBaseDir);
341
342 4
            $this->storeVersion($resource);
343
344 4
            return;
345
        }
346
347 307
        if ($hasBody) {
348 175
            file_put_contents($pathInBaseDir, $resource->getBody());
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Puli\Repository\Api\Resource\PuliResource as the method getBody() does only exist in the following implementations of said interface: Puli\Repository\Resource\FileResource.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
349
350 175
            $this->storeVersion($resource);
351
352 175
            return;
353
        }
354
355 239
        if (is_file($pathInBaseDir)) {
356
            $this->filesystem->remove($pathInBaseDir);
357
        }
358
359 239
        if (!file_exists($pathInBaseDir)) {
360 147
            mkdir($pathInBaseDir, 0777, true);
361
        }
362
363 239
        foreach ($resource->listChildren() as $child) {
364 148
            $this->addResource($path.'/'.$child->getName(), $child, false);
365
        }
366
367 239
        $this->storeVersion($resource);
368 239
    }
369
370 48
    private function removeResource($filesystemPath, &$removed)
371
    {
372
        // Skip paths that have already been removed
373 48
        if (!file_exists($filesystemPath)) {
374
            return;
375
        }
376
377 48
        $this->removeVersions($this->getPath($filesystemPath));
378
379 48
        ++$removed;
380
381 48
        if (is_dir($filesystemPath)) {
382 32
            $iterator = $this->getDirectoryIterator($filesystemPath);
383
384 32
            foreach ($iterator as $childFilesystemPath) {
385
                // Remove children and child versions
386 32
                $this->removeResource($childFilesystemPath, $removed);
387
            }
388
        }
389
390 48
        $this->filesystem->remove($filesystemPath);
391 48
    }
392
393 147
    private function createResource($filesystemPath, $path)
394
    {
395 147
        $resource = null;
396
397 147
        if (is_link($filesystemPath)) {
398 6
            $baseDir = rtrim($this->baseDir, '/');
399 6
            $targetFilesystemPath = $this->readLink($filesystemPath);
400
401 6
            if (Path::isBasePath($baseDir, $targetFilesystemPath)) {
402 6
                $targetPath = '/'.Path::makeRelative($targetFilesystemPath, $baseDir);
403 6
                $resource = new LinkResource($targetPath);
404
            }
405
        }
406
407 147
        if (!$resource && is_dir($filesystemPath)) {
408 98
            $resource = new DirectoryResource($filesystemPath);
409
        }
410
411 147
        if (!$resource) {
412 79
            $resource = new FileResource($filesystemPath);
413
        }
414
415 147
        $resource->attachTo($this, $path);
416
417 147
        return $resource;
418
    }
419
420 50
    private function iteratorToCollection(Iterator $iterator)
421
    {
422 50
        $filesystemPaths = iterator_to_array($iterator);
423 50
        $resources = array();
424
425
        // RecursiveDirectoryIterator is not guaranteed to return sorted results
426 50
        sort($filesystemPaths);
427
428 50
        foreach ($filesystemPaths as $filesystemPath) {
429 42
            $resource = is_dir($filesystemPath)
430 24
                ? new DirectoryResource($filesystemPath, $this->getPath($filesystemPath))
431 42
                : new FileResource($filesystemPath, $this->getPath($filesystemPath));
432
433 42
            $resource->attachTo($this);
434
435 42
            $resources[] = $resource;
436
        }
437
438 50
        return new FilesystemResourceCollection($resources);
439
    }
440
441 66 View Code Duplication
    private function getFilesystemPath($path)
442
    {
443 66
        $path = $this->sanitizePath($path);
444 42
        $filesystemPath = $this->baseDir.$path;
445
446 42
        if (!file_exists($filesystemPath)) {
447 8
            throw ResourceNotFoundException::forPath($path);
448
        }
449
450 34
        return $filesystemPath;
451
    }
452
453 135
    private function getGlobIterator($query, $language)
454
    {
455 135
        $this->failUnlessGlob($language);
456
457 132
        Assert::stringNotEmpty($query, 'The glob must be a non-empty string. Got: %s');
458 108
        Assert::startsWith($query, '/', 'The glob %s is not absolute.');
459
460 96
        $query = Path::canonicalize($query);
461
462 96
        return new GlobIterator($this->baseDir.$query);
463
    }
464
465 82
    private function getDirectoryIterator($filesystemPath)
466
    {
467 82
        return new RecursiveDirectoryIterator(
468
            $filesystemPath,
469 82
            RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS
470
        );
471
    }
472
473 30
    private function symlinkMirror($origin, $target, array $dirsToKeep = array())
474
    {
475 30
        $targetIsDir = is_dir($target);
476 30
        $forceDir = in_array($target, $dirsToKeep, true);
477
478
        // Merge directories
479 30
        if (is_dir($origin) && ($targetIsDir || $forceDir)) {
480 16
            if (is_link($target)) {
481 4
                $this->replaceLinkByCopy($target, $dirsToKeep);
482
            }
483
484 16
            $iterator = $this->getDirectoryIterator($origin);
485
486 16
            foreach ($iterator as $path) {
487 16
                $this->symlinkMirror($path, $target.'/'.basename($path), $dirsToKeep);
488
            }
489
490 16
            return;
491
        }
492
493
        // Replace target
494 30
        if (file_exists($target)) {
495 10
            $this->filesystem->remove($target);
496
        }
497
498
        // Try creating a relative link
499 30
        if ($this->relative && $this->trySymlink(Path::makeRelative($origin, Path::getDirectory($target)), $target)) {
500 15
            return;
501
        }
502
503
        // Try creating a absolute link
504 15
        if ($this->trySymlink($origin, $target)) {
505 15
            return;
506
        }
507
508
        // Fall back to copy
509
        if (is_dir($origin)) {
510
            $this->filesystem->mirror($origin, $target);
511
512
            return;
513
        }
514
515
        $this->filesystem->copy($origin, $target);
516
    }
517
518 182
    private function replaceParentSymlinksByCopies($path)
519
    {
520 182
        $previousPath = null;
521
522
        // Collect all paths that MUST NOT be symlinks after doing the
523
        // replace operation.
524
        //
525
        // Example:
526
        //
527
        // $dirsToKeep = ['/path/to/webmozart', '/path/to/webmozart/views']
528
        //
529
        // Before:
530
        //   /webmozart -> target
531
        //
532
        // After:
533
        //   /webmozart
534
        //     /config -> target/config
535
        //     /views
536
        //       /index.html.twig -> target/views/index.html.twig
537
538 182
        $dirsToKeep = array();
539
540 182
        while ($previousPath !== ($path = Path::getDirectory($path))) {
541 182
            $filesystemPath = $this->baseDir.$path;
542 182
            $dirsToKeep[] = $filesystemPath;
543
544 182
            if (is_link($filesystemPath)) {
545 12
                $this->replaceLinkByCopy($filesystemPath, $dirsToKeep);
546
547 12
                return;
548
            }
549
550 182
            $previousPath = $path;
551
        }
552 182
    }
553
554 16
    private function replaceLinkByCopy($path, array $dirsToKeep = array())
555
    {
556 16
        $target = Path::makeAbsolute($this->readLink($path), Path::getDirectory($path));
557 16
        $this->filesystem->remove($path);
558 16
        $this->filesystem->mkdir($path);
559 16
        $this->symlinkMirror($target, $path, $dirsToKeep);
560 16
    }
561
562 30
    private function trySymlink($origin, $target)
563
    {
564
        try {
565 30
            $this->filesystem->symlink($origin, $target);
566
567 30
            if (file_exists($target)) {
568 30
                return true;
569
            }
570
        } catch (IOException $e) {
571
        }
572
573
        return false;
574
    }
575
576 22
    private function readLink($filesystemPath)
577
    {
578
        // On Windows, transitive links are resolved to the final target by
579
        // readlink(). realpath(), however, returns the target link on Windows,
580
        // but not on Unix.
581
582
        // /link1 -> /link2 -> /file
583
584
        // Windows: readlink(/link1) => /file
585
        //          realpath(/link1) => /link2
586
587
        // Unix:    readlink(/link1) => /link2
588
        //          realpath(/link1) => /file
589
590
        // Consistency FTW!
591
592 22
        return '\\' === DIRECTORY_SEPARATOR ? realpath($filesystemPath) : readlink($filesystemPath);
593
    }
594
595 90
    private function getPath($filesystemPath)
596
    {
597 90
        return substr($filesystemPath, $this->baseDirLength);
598
    }
599
}
600