Completed
Pull Request — 1.0 (#58)
by Titouan
04:36 queued 01:59
created

FilesystemRepository::getGlobIterator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

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