Failed Conditions
Pull Request — 1.0 (#64)
by Titouan
04:36
created

FilesystemRepository::followLinks()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 3
ccs 0
cts 2
cp 0
rs 10
cc 1
eloc 1
nc 1
nop 1
crap 2
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\EditableRepository;
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 RecursiveIteratorIterator;
28
use Symfony\Component\Filesystem\Exception\IOException;
29
use Symfony\Component\Filesystem\Filesystem;
30
use Webmozart\Assert\Assert;
31
use Webmozart\Glob\Iterator\GlobIterator;
32
use Webmozart\Glob\Iterator\RecursiveDirectoryIterator;
33
use Webmozart\PathUtil\Path;
34
35
/**
36
 * A repository reading from the file system.
37
 *
38
 * Resources can be read using their absolute file system paths:
39
 *
40
 * ```php
41
 * use Puli\Repository\FilesystemRepository;
42
 *
43
 * $repo = new FilesystemRepository();
44
 * $resource = $repo->get('/home/puli/.gitconfig');
45
 * ```
46
 *
47
 * The returned resources implement {@link FilesystemResource}.
48
 *
49
 * Optionally, a root directory can be passed to the constructor. Then all paths
50
 * will be read relative to that directory:
51
 *
52
 * ```php
53
 * $repo = new FilesystemRepository('/home/puli');
54
 * $resource = $repo->get('/.gitconfig');
55
 * ```
56
 *
57
 * While "." and ".." segments are supported, files outside the root directory
58
 * cannot be read. Any leading ".." segments will simply be stripped off.
59
 *
60
 * @since  1.0
61
 *
62
 * @author Bernhard Schussek <[email protected]>
63
 */
64
class FilesystemRepository extends AbstractRepository implements EditableRepository
65
{
66
    /**
67
     * @var bool|null
68
     */
69
    private static $symlinkSupported;
70
71
    /**
72
     * @var string
73
     */
74
    private $baseDir;
75
76
    /**
77
     * @var bool
78
     */
79
    private $symlink;
80
81
    /**
82
     * @var bool
83
     */
84
    private $relative;
85
86
    /**
87
     * @var Filesystem
88
     */
89
    private $filesystem;
90
91
    /**
92
     * Returns whether symlinks are supported in the local environment.
93
     *
94
     * @return bool Returns `true` if symlinks are supported.
95
     */
96 256
    public static function isSymlinkSupported()
97
    {
98 256
        if (null === self::$symlinkSupported) {
99
            // http://php.net/manual/en/function.symlink.php
100
            // Symlinks are only supported on Windows Vista, Server 2008 or
101
            // greater on PHP 5.3+
102 1
            if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
103
                self::$symlinkSupported = PHP_WINDOWS_VERSION_MAJOR >= 6;
104
            } else {
105 1
                self::$symlinkSupported = true;
106
            }
107 1
        }
108
109 256
        return self::$symlinkSupported;
110
    }
111
112
    /**
113
     * Creates a new repository.
114
     *
115
     * @param string $baseDir  The base directory of the repository on the file
116
     *                         system.
117
     * @param bool   $symlink  Whether to use symbolic links for added files. If
118
     *                         symbolic links are not supported on the current
119
     *                         system, the repository will create hard copies
120
     *                         instead.
121
     * @param bool   $relative Whether to create relative symbolic links. If
122
     *                         relative links are not supported on the current
123
     *                         system, the repository will create absolute links
124
     *                         instead.
125
     */
126 330
    public function __construct($baseDir = '/', $symlink = true, $relative = true)
127
    {
128 330
        Assert::directory($baseDir);
129 330
        Assert::boolean($symlink);
130
131 330
        $this->baseDir = rtrim(Path::canonicalize($baseDir), '/');
132 330
        $this->symlink = $symlink && self::isSymlinkSupported();
133 330
        $this->relative = $this->symlink && $relative;
134 330
        $this->filesystem = new Filesystem();
135 330
    }
136
137
    /**
138
     * {@inheritdoc}
139
     */
140 135 View Code Duplication
    public function get($path)
141
    {
142 135
        $path = $this->sanitizePath($path);
143 123
        $filesystemPath = $this->baseDir.$path;
144
145 123
        if (!file_exists($filesystemPath)) {
146 4
            throw ResourceNotFoundException::forPath($path);
147
        }
148
149 119
        return $this->createResource($filesystemPath, $path);
150
    }
151
152
    /**
153
     * {@inheritdoc}
154
     */
155 41
    public function find($query, $language = 'glob')
156
    {
157 41
        return $this->iteratorToCollection($this->getGlobIterator($query, $language));
158
    }
159
160
    /**
161
     * {@inheritdoc}
162
     */
163 61
    public function contains($query, $language = 'glob')
164
    {
165 61
        $iterator = $this->getGlobIterator($query, $language);
166 48
        $iterator->rewind();
167
168 48
        return $iterator->valid();
169
    }
170
171
    /**
172
     * {@inheritdoc}
173
     */
174 28
    public function hasChildren($path)
175
    {
176 28
        $filesystemPath = $this->getFilesystemPath($path);
177
178 12
        if (!is_dir($filesystemPath)) {
179 4
            return false;
180
        }
181
182 12
        $iterator = $this->getDirectoryIterator($filesystemPath);
183 12
        $iterator->rewind();
184
185 12
        return $iterator->valid();
186
    }
187
188
    /**
189
     * {@inheritdoc}
190
     */
191 42
    public function listChildren($path)
192
    {
193 42
        $filesystemPath = $this->getFilesystemPath($path);
194
195 26
        if (!is_dir($filesystemPath)) {
196 4
            return new FilesystemResourceCollection();
197
        }
198
199 22
        return $this->iteratorToCollection($this->getDirectoryIterator($filesystemPath));
200
    }
201
202
    /**
203
     * {@inheritdoc}
204
     */
205 295
    public function add($path, $resource)
206
    {
207 295
        $path = $this->sanitizePath($path);
208
209 283 View Code Duplication
        if ($resource instanceof ResourceCollection) {
210 4
            $this->ensureDirectoryExists($path);
211 4
            foreach ($resource as $child) {
212 4
                $this->addResource($path.'/'.$child->getName(), $child);
213 4
            }
214
215 4
            return;
216
        }
217
218 279
        if ($resource instanceof PuliResource) {
219 275
            $this->ensureDirectoryExists(Path::getDirectory($path));
220 275
            $this->addResource($path, $resource);
221
222 268
            return;
223
        }
224
225 4
        throw new UnsupportedResourceException(sprintf(
226 4
            'The passed resource must be a PuliResource or ResourceCollection. Got: %s',
227 4
            is_object($resource) ? get_class($resource) : gettype($resource)
228 4
        ));
229
    }
230
231
    /**
232
     * {@inheritdoc}
233
     */
234 49
    public function remove($query, $language = 'glob')
235
    {
236 49
        $iterator = $this->getGlobIterator($query, $language);
237 36
        $removed = 0;
238
239 36
        Assert::notEmpty(trim($query, '/'), 'The root directory cannot be removed.');
240
241
        // There's some problem with concurrent deletions at the moment
242 28
        foreach (iterator_to_array($iterator) as $filesystemPath) {
243 28
            $this->removeResource($filesystemPath, $removed);
244 28
        }
245
246 28
        return $removed;
247
    }
248
249
    /**
250
     * {@inheritdoc}
251
     */
252 4
    public function clear()
253
    {
254 4
        $iterator = $this->getDirectoryIterator($this->baseDir);
255 4
        $removed = 0;
256
257 4
        foreach ($iterator as $filesystemPath) {
258 4
            $this->removeResource($filesystemPath, $removed);
259 4
        }
260
261 4
        return $removed;
262
    }
263
264 279
    private function ensureDirectoryExists($path)
265
    {
266 279
        $filesystemPath = $this->baseDir.$path;
267
268 279
        if (is_file($filesystemPath)) {
269 1
            throw new UnsupportedOperationException(sprintf(
270
                'Instances of BodyResource do not support child resources in '.
271 1
                'FilesystemRepository. Tried to add a child to %s.',
272
                $filesystemPath
273 1
            ));
274
        }
275
276 279
        if (!is_dir($filesystemPath)) {
277 80
            mkdir($filesystemPath, 0777, true);
278 80
        }
279 279
    }
280
281 279
    private function addResource($path, PuliResource $resource, $checkParentsForSymlinks = true)
282
    {
283 279
        $pathInBaseDir = $this->baseDir.$path;
284 279
        $hasChildren = $resource->hasChildren();
285 279
        $hasBody = $resource instanceof BodyResource;
286
287 279
        if ($hasChildren && $hasBody) {
288 1
            throw new UnsupportedResourceException(sprintf(
289
                'Instances of BodyResource do not support child resources in '.
290 1
                'FilesystemRepository. Tried to add a BodyResource with '.
291 1
                'children at %s.',
292
                $path
293 1
            ));
294
        }
295
296 278
        if ($this->symlink && $checkParentsForSymlinks) {
297 150
            $this->replaceParentSymlinksByCopies($path);
298 150
        }
299
300 278
        if ($resource instanceof FilesystemResource) {
301 33
            if ($this->symlink) {
302 30
                $this->symlinkMirror($resource->getFilesystemPath(), $pathInBaseDir);
303 33
            } elseif ($hasBody) {
304 2
                $this->filesystem->copy($resource->getFilesystemPath(), $pathInBaseDir);
305 2
            } else {
306 1
                $this->filesystem->mirror($resource->getFilesystemPath(), $pathInBaseDir);
307
            }
308
309 33
            return;
310
        }
311
312 253
        if ($resource instanceof LinkResource) {
313 10
            if (!$this->symlink) {
314 6
                throw new UnsupportedResourceException(sprintf(
315
                    'LinkResource requires support of symbolic links in FilesystemRepository. '.
316 6
                    'Tried to add a LinkResource at %s.',
317
                    $path
318 6
                ));
319
            }
320
321 4
            $this->filesystem->symlink($this->baseDir.$resource->getTargetPath(), $pathInBaseDir);
322
323 4
            return;
324
        }
325
326 245
        if ($hasBody) {
327 121
            file_put_contents($pathInBaseDir, $resource->getBody());
328
329 121
            return;
330
        }
331
332 193
        if (is_file($pathInBaseDir)) {
333
            $this->filesystem->remove($pathInBaseDir);
334
        }
335
336 193
        if (!file_exists($pathInBaseDir)) {
337 117
            mkdir($pathInBaseDir, 0777, true);
338 117
        }
339
340 193
        foreach ($resource->listChildren() as $child) {
341 114
            $this->addResource($path.'/'.$child->getName(), $child, false);
342 193
        }
343 193
    }
344
345 32
    private function removeResource($filesystemPath, &$removed)
346
    {
347
        // Skip paths that have already been removed
348 32
        if (!file_exists($filesystemPath)) {
349
            return;
350
        }
351
352 32
        ++$removed;
353
354 32
        if (is_dir($filesystemPath)) {
355 24
            $removed += $this->countChildren($filesystemPath);
356 24
        }
357
358 32
        $this->filesystem->remove($filesystemPath);
359 32
    }
360
361 119
    private function createResource($filesystemPath, $path)
362
    {
363 119
        $resource = null;
364
365 119
        if (is_link($filesystemPath)) {
366 6
            $baseDir = rtrim($this->baseDir, '/');
367 6
            $targetFilesystemPath = $this->readLink($filesystemPath);
368
369 6
            if (Path::isBasePath($baseDir, $targetFilesystemPath)) {
370 6
                $targetPath = '/'.Path::makeRelative($targetFilesystemPath, $baseDir);
371 6
                $resource = new LinkResource($targetPath);
372 6
            }
373 6
        }
374
375 119
        if (!$resource && is_dir($filesystemPath)) {
376 74
            $resource = new DirectoryResource($filesystemPath);
377 74
        }
378
379 119
        if (!$resource) {
380 67
            $resource = new FileResource($filesystemPath);
381 67
        }
382
383 119
        $resource->attachTo($this, $path);
384
385 119
        return $resource;
386
    }
387
388 24
    private function countChildren($filesystemPath)
389
    {
390 24
        $iterator = new RecursiveIteratorIterator(
391 24
            $this->getDirectoryIterator($filesystemPath),
392
            RecursiveIteratorIterator::SELF_FIRST
393 24
        );
394
395 24
        $iterator->rewind();
396 24
        $count = 0;
397
398 24
        while ($iterator->valid()) {
399 24
            ++$count;
400 24
            $iterator->next();
401 24
        }
402
403 24
        return $count;
404
    }
405
406 50
    private function iteratorToCollection(Iterator $iterator)
407
    {
408 50
        $offset = strlen($this->baseDir);
409 50
        $filesystemPaths = iterator_to_array($iterator);
410 50
        $resources = array();
411
412
        // RecursiveDirectoryIterator is not guaranteed to return sorted results
413 50
        sort($filesystemPaths);
414
415 50
        foreach ($filesystemPaths as $filesystemPath) {
416 42
            $path = substr($filesystemPath, $offset);
417
418 42
            $resource = is_dir($filesystemPath)
419 42
                ? new DirectoryResource($filesystemPath, $path)
420 42
                : new FileResource($filesystemPath, $path);
421
422 42
            $resource->attachTo($this);
423
424 42
            $resources[] = $resource;
425 50
        }
426
427 50
        return new FilesystemResourceCollection($resources);
428
    }
429
430 66 View Code Duplication
    private function getFilesystemPath($path)
431
    {
432 66
        $path = $this->sanitizePath($path);
433 42
        $filesystemPath = $this->baseDir.$path;
434
435 42
        if (!file_exists($filesystemPath)) {
436 8
            throw ResourceNotFoundException::forPath($path);
437
        }
438
439 34
        return $filesystemPath;
440
    }
441
442 123
    private function getGlobIterator($query, $language)
443
    {
444 123
        $this->validateSearchLanguage($language);
445
446 120
        Assert::stringNotEmpty($query, 'The glob must be a non-empty string. Got: %s');
447 96
        Assert::startsWith($query, '/', 'The glob %s is not absolute.');
448
449 84
        $query = Path::canonicalize($query);
450
451 84
        return new GlobIterator($this->baseDir.$query);
452
    }
453
454 70
    private function getDirectoryIterator($filesystemPath)
455
    {
456 70
        return new RecursiveDirectoryIterator(
457 70
            $filesystemPath,
458 70
            RecursiveDirectoryIterator::CURRENT_AS_PATHNAME | RecursiveDirectoryIterator::SKIP_DOTS
459 70
        );
460
    }
461
462 30
    private function symlinkMirror($origin, $target, array $dirsToKeep = array())
463
    {
464 30
        $targetIsDir = is_dir($target);
465 30
        $forceDir = in_array($target, $dirsToKeep, true);
466
467
        // Merge directories
468 30
        if (is_dir($origin) && ($targetIsDir || $forceDir)) {
469 16
            if (is_link($target)) {
470 4
                $this->replaceLinkByCopy($target, $dirsToKeep);
471 4
            }
472
473 16
            $iterator = $this->getDirectoryIterator($origin);
474
475 16
            foreach ($iterator as $path) {
476 16
                $this->symlinkMirror($path, $target.'/'.basename($path), $dirsToKeep);
477 16
            }
478
479 16
            return;
480
        }
481
482
        // Replace target
483 30
        if (file_exists($target)) {
484 10
            $this->filesystem->remove($target);
485 10
        }
486
487
        // Try creating a relative link
488 30
        if ($this->relative && $this->trySymlink(Path::makeRelative($origin, Path::getDirectory($target)), $target)) {
489 15
            return;
490
        }
491
492
        // Try creating a absolute link
493 15
        if ($this->trySymlink($origin, $target)) {
494 15
            return;
495
        }
496
497
        // Fall back to copy
498
        if (is_dir($origin)) {
499
            $this->filesystem->mirror($origin, $target);
500
501
            return;
502
        }
503
504
        $this->filesystem->copy($origin, $target);
505
    }
506
507 150
    private function replaceParentSymlinksByCopies($path)
508
    {
509 150
        $previousPath = null;
510
511
        // Collect all paths that MUST NOT be symlinks after doing the
512
        // replace operation.
513
        //
514
        // Example:
515
        //
516
        // $dirsToKeep = ['/path/to/webmozart', '/path/to/webmozart/views']
517
        //
518
        // Before:
519
        //   /webmozart -> target
520
        //
521
        // After:
522
        //   /webmozart
523
        //     /config -> target/config
524
        //     /views
525
        //       /index.html.twig -> target/views/index.html.twig
526
527 150
        $dirsToKeep = array();
528
529 150
        while ($previousPath !== ($path = Path::getDirectory($path))) {
530 150
            $filesystemPath = $this->baseDir.$path;
531 150
            $dirsToKeep[] = $filesystemPath;
532
533 150
            if (is_link($filesystemPath)) {
534 12
                $this->replaceLinkByCopy($filesystemPath, $dirsToKeep);
535
536 12
                return;
537
            }
538
539 150
            $previousPath = $path;
540 150
        }
541 150
    }
542
543 16
    private function replaceLinkByCopy($path, array $dirsToKeep = array())
544
    {
545 16
        $target = Path::makeAbsolute($this->readLink($path), Path::getDirectory($path));
546 16
        $this->filesystem->remove($path);
547 16
        $this->filesystem->mkdir($path);
548 16
        $this->symlinkMirror($target, $path, $dirsToKeep);
549 16
    }
550
551 30
    private function trySymlink($origin, $target)
552
    {
553
        try {
554 30
            $this->filesystem->symlink($origin, $target);
555
556 30
            if (file_exists($target)) {
557 30
                return true;
558
            }
559
        } catch (IOException $e) {
560
        }
561
562
        return false;
563
    }
564
565 22
    private function readLink($filesystemPath)
566
    {
567
        // On Windows, transitive links are resolved to the final target by
568
        // readlink(). realpath(), however, returns the target link on Windows,
569
        // but not on Unix.
570
571
        // /link1 -> /link2 -> /file
572
573
        // Windows: readlink(/link1) => /file
574
        //          realpath(/link1) => /link2
575
576
        // Unix:    readlink(/link1) => /link2
577
        //          realpath(/link1) => /file
578
579
        // Consistency FTW!
580
581 22
        return '\\' === DIRECTORY_SEPARATOR ? realpath($filesystemPath) : readlink($filesystemPath);
582
    }
583
584
    /**
585
     * This repository does not need this as the filesystem will resolve itself links.
586
     */
587
    protected function followLinks($path)
588
    {
589
    }
590
}
591