Completed
Push — master ( cb7793...7b1e0a )
by Arne
01:36
created

Vault::setExclusions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 1
1
<?php
2
3
namespace Archivr;
4
5
use Archivr\ConnectionAdapter\ConnectionAdapterInterface;
6
use Archivr\Exception\Exception;
7
use Archivr\IndexMerger\IndexMergerInterface;
8
use Archivr\IndexMerger\StandardIndexMerger;
9
use Archivr\LockAdapter\ConnectionBasedLockAdapter;
10
use Archivr\LockAdapter\LockAdapterInterface;
11
use Ramsey\Uuid\Uuid;
12
use Symfony\Component\Finder\Finder;
13
use Symfony\Component\Finder\SplFileInfo;
14
use Archivr\Operation\ChmodOperation;
15
use Archivr\Operation\DownloadOperation;
16
use Archivr\Operation\MkdirOperation;
17
use Archivr\Operation\OperationInterface;
18
use Archivr\Operation\SymlinkOperation;
19
use Archivr\Operation\TouchOperation;
20
use Archivr\Operation\UnlinkOperation;
21
use Archivr\Operation\UploadOperation;
22
23
class Vault
24
{
25
    use TildeExpansionTrait;
26
27
    const METADATA_DIRECTORY_NAME = '.archivr';
28
    const SYNCHRONIZATION_LIST_FILE_NAME = 'index';
29
    const LOCK_SYNC = 'sync';
30
31
    /**
32
     * @var string
33
     */
34
    protected $title;
35
36
    /**
37
     * @var ConnectionAdapterInterface
38
     */
39
    protected $vaultConnection;
40
41
    /**
42
     * @var string
43
     */
44
    protected $localPath;
45
46
    /**
47
     * @var LockAdapterInterface
48
     */
49
    protected $lockAdapter;
50
51
    /**
52
     * @var IndexMergerInterface
53
     */
54
    protected $indexMerger;
55
56
    /**
57
     * @var string[]
58
     */
59
    protected $exclusions = [];
60
61
62
    public function __construct(string $title, string $localPath, ConnectionAdapterInterface $vaultConnection)
63
    {
64
        $this->title = $title;
65
        $this->vaultConnection = $vaultConnection;
66
        $this->localPath = rtrim($this->expandTildePath($localPath), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
67
    }
68
69
    public function getTitle(): string
70
    {
71
        return $this->title;
72
    }
73
74
    public function getLocalPath(): string
75
    {
76
        return $this->localPath;
77
    }
78
79
    public function setIndexMerger(IndexMergerInterface $indexMerger = null)
80
    {
81
        $this->indexMerger = $indexMerger;
82
83
        return $this;
84
    }
85
86
    public function getIndexMerger(): IndexMergerInterface
87
    {
88
        if ($this->indexMerger === null)
89
        {
90
            $this->indexMerger = new StandardIndexMerger();
91
        }
92
93
        return $this->indexMerger;
94
    }
95
96
    public function setLockAdapter(LockAdapterInterface $lockAdapter = null): Vault
97
    {
98
        $this->lockAdapter = $lockAdapter;
99
100
        return $this;
101
    }
102
103
    public function getLockAdapter(): LockAdapterInterface
104
    {
105
        if ($this->lockAdapter === null)
106
        {
107
            $this->lockAdapter = new ConnectionBasedLockAdapter($this->vaultConnection);
108
        }
109
110
        return $this->lockAdapter;
111
    }
112
113
    public function getVaultConnection(): ConnectionAdapterInterface
114
    {
115
        return $this->vaultConnection;
116
    }
117
118
    public function getExclusions(): array
119
    {
120
        return $this->exclusions;
121
    }
122
123
    public function addExclusion(string $path): Vault
124
    {
125
        $this->exclusions[] = $path;
126
127
        return $this;
128
    }
129
130
    public function setExclusions(array $paths): Vault
131
    {
132
        $this->exclusions = array_values($paths);
0 ignored issues
show
Documentation Bug introduced by
It seems like array_values($paths) of type array<integer,?> is incompatible with the declared type array<integer,string> of property $exclusions.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
133
134
        return $this;
135
    }
136
137
    /**
138
     * Builds and returns an index representing the current local state.
139
     *
140
     * @return Index
141
     */
142
    public function buildLocalIndex(): Index
143
    {
144
        $finder = new Finder();
145
        $finder->in($this->localPath);
146
        $finder->ignoreDotFiles(false);
147
        $finder->ignoreVCS(true);
148
        $finder->exclude(static::METADATA_DIRECTORY_NAME);
149
        $finder->notPath('archivr.json');
150
151
        foreach ($this->exclusions as $path)
152
        {
153
            $finder->notPath($path);
154
        }
155
156
        $index = new Index();
157
158
        foreach ($finder->directories() as $fileInfo)
159
        {
160
            /** @var SplFileInfo $fileInfo */
161
162
            $index->addObject(IndexObject::fromPath($this->localPath, $fileInfo->getRelativePathname()));
163
        }
164
165
        foreach ($finder->files() as $fileInfo)
166
        {
167
            /** @var SplFileInfo $fileInfo */
168
169
            $index->addObject(IndexObject::fromPath($this->localPath, $fileInfo->getRelativePathname()));
170
        }
171
172
        return $index;
173
    }
174
175
    /**
176
     * Reads and returns the index representing the local state on the last synchronization.
177
     *
178
     * @return Index
179
     * @throws Exception
180
     */
181
    public function loadLastLocalIndex()
182
    {
183
        $index = null;
184
        $path = $this->getLastLocalIndexFilePath();
185
186
        if (is_file($path))
187
        {
188
            $stream = fopen($path, 'rb');
189
            $indexModificationDate = \DateTime::createFromFormat('U', filemtime($path));
190
191
            if (!($indexModificationDate instanceof \DateTime))
192
            {
193
                throw new Exception();
194
            }
195
196
            $index = $this->readIndexFromStream($stream, $indexModificationDate);
197
198
            fclose($stream);
199
        }
200
201
        return $index;
202
    }
203
204
    /**
205
     * Reads and returns the current remote index.
206
     *
207
     * @param int $revision Revision to load. Defaults to the last revision.
208
     *
209
     * @return Index
210
     */
211
    public function loadRemoteIndex(int $revision = null)
212
    {
213
        $list = null;
214
215
        if ($revision === null)
216
        {
217
            $list = $this->loadSynchronizationList();
218
219
            if (!$list || !$list->getLastSynchronization())
220
            {
221
                return null;
222
            }
223
224
            $revision = $list->getLastSynchronization()->getRevision();
225
        }
226
227
        return $this->doLoadRemoteIndex($revision, $list);
228
    }
229
230
    /**
231
     * Computes and returns the index representing the vault state after the local index has been merged with the remote index.
232
     *
233
     * @return Index
234
     */
235
    public function buildMergedIndex(): Index
236
    {
237
        return $this->doBuildMergedIndex();
238
    }
239
240
    /**
241
     * Returns ordered collection of operations required to synchronize the vault with the local path.
242
     * In addition to the object specific operations contained in the returned OperationCollection additional operations
243
     * might be necessary like index updates that do not belong to specific index objects.
244
     *
245
     * @return OperationCollection
246
     */
247
    public function getOperationCollection(): OperationCollection
248
    {
249
        return $this->doGetOperationCollection();
250
    }
251
252
    /**
253
     * Synchronizes the local with the remote state by executing all operations returned by getOperationCollection() (broadly speaking).
254
     *
255
     * @param int $newRevision
256
     * @param string $identity
257
     * @param SynchronizationProgressListenerInterface $progressionListener
258
     *
259
     * @return OperationResultCollection
260
     * @throws Exception
261
     */
262
    public function synchronize(int $newRevision = null, string $identity = null, SynchronizationProgressListenerInterface $progressionListener = null): OperationResultCollection
263
    {
264
        if ($progressionListener === null)
265
        {
266
            $progressionListener = new DummySynchronizationProgressListener();
267
        }
268
269
        $localIndex = $this->buildLocalIndex();
270
        $lastLocalIndex = $this->loadLastLocalIndex();
271
272
273
        if (!$this->getLockAdapter()->acquireLock(static::LOCK_SYNC))
274
        {
275
            throw new Exception('Failed to acquire lock.');
276
        }
277
278
279
        $synchronizationList = $this->loadSynchronizationList() ?: new SynchronizationList();
280
        $lastSynchronization = $synchronizationList->getLastSynchronization();
281
282
        if ($lastSynchronization)
283
        {
284
            $newRevision = $newRevision ?: ($lastSynchronization->getRevision() + 1);
285
            $remoteIndex = $this->doLoadRemoteIndex($lastSynchronization->getRevision(), $synchronizationList);
286
        }
287
        else
288
        {
289
            $newRevision = $newRevision ?: 1;
290
            $remoteIndex = null;
291
        }
292
293
        $synchronization = new Synchronization($newRevision, $this->generateNewBlobId(), new \DateTime(), $identity);
294
        $synchronizationList->addSynchronization($synchronization);
295
296
297
        $mergedIndex = $this->doBuildMergedIndex($localIndex, $lastLocalIndex, $remoteIndex);
298
        $operationCollection = $this->doGetOperationCollection($localIndex, $remoteIndex, $mergedIndex);
299
300
        $operationResultCollection = new OperationResultCollection();
301
302
        // operation count +
303
        // merged index write +
304
        // copy merged index to vault +
305
        // save merged index as last local index +
306
        // upload synchronization list +
307
        // release lock
308
        $progressionListener->start(count($operationCollection) + 5);
309
310
        foreach ($operationCollection as $operation)
311
        {
312
            /** @var OperationInterface $operation */
313
314
            $success = $operation->execute();
315
316
            $operationResult = new OperationResult($operation, $success);
317
            $operationResultCollection->addOperationResult($operationResult);
318
319
            $progressionListener->advance();
320
        }
321
322
        // dump new index
323
        $mergedIndexFilePath = $this->writeIndexToTemporaryFile($mergedIndex);
324
325
        $progressionListener->advance();
326
327
        // upload new index
328
        $readStream = fopen($mergedIndexFilePath, 'rb');
329
        $compressionFilter = stream_filter_append($readStream, 'zlib.deflate');
330
        $this->vaultConnection->writeStream($synchronization->getBlobId(), $readStream);
331
        rewind($readStream);
332
        stream_filter_remove($compressionFilter);
333
334
        $progressionListener->advance();
335
336
        // save new index locally
337
        $writeStream = fopen($this->getLastLocalIndexFilePath(), 'wb');
338
        stream_copy_to_stream($readStream, $writeStream);
339
        fclose($writeStream);
340
        fclose($readStream);
341
342
        $progressionListener->advance();
343
344
        // upload new synchronization list
345
        $synchronizationListFilePath = $this->writeSynchronizationListToTemporaryFile($synchronizationList);
346
        $readStream = fopen($synchronizationListFilePath, 'rb');
347
        stream_filter_append($readStream, 'zlib.deflate');
348
        $this->vaultConnection->writeStream(static::SYNCHRONIZATION_LIST_FILE_NAME, $readStream);
349
        fclose($readStream);
350
351
        // release lock
352
        if (!$this->getLockAdapter()->releaseLock(static::LOCK_SYNC))
353
        {
354
            throw new Exception('Failed to release lock.');
355
        }
356
357
        $progressionListener->advance();
358
        $progressionListener->finish();
359
360
        return $operationResultCollection;
361
    }
362
363
    /**
364
     * Loads and returns the list of synchronizations from the vault.
365
     *
366
     * @return SynchronizationList
367
     */
368
    public function loadSynchronizationList()
369
    {
370
        $list = null;
371
372
        if ($this->vaultConnection->exists(static::SYNCHRONIZATION_LIST_FILE_NAME))
373
        {
374
            $stream = $this->vaultConnection->getReadStream(static::SYNCHRONIZATION_LIST_FILE_NAME);
375
376
            stream_filter_append($stream, 'zlib.inflate');
377
378
            $list = $this->readSynchronizationListFromStream($stream);
379
380
            fclose($stream);
381
        }
382
383
        return $list;
384
    }
385
386
    protected function doLoadRemoteIndex(int $revision, SynchronizationList $synchronizationList = null)
387
    {
388
        if ($synchronizationList === null)
389
        {
390
            $synchronizationList = $this->loadSynchronizationList();
391
        }
392
393
        $synchronization = $synchronizationList->getSynchronizationByRevision($revision);
0 ignored issues
show
Bug introduced by
It seems like $synchronizationList is not always an object, but can also be of type null. Maybe add an additional type check?

If a variable is not always an object, we recommend to add an additional type check to ensure your method call is safe:

function someFunction(A $objectMaybe = null)
{
    if ($objectMaybe instanceof A) {
        $objectMaybe->doSomething();
    }
}
Loading history...
394
395
        if (!$synchronization)
396
        {
397
            return null;
398
        }
399
400
        $index = null;
401
402
        if ($this->vaultConnection->exists($synchronization->getBlobId()))
403
        {
404
            $stream = $this->vaultConnection->getReadStream($synchronization->getBlobId());
405
406
            stream_filter_append($stream, 'zlib.inflate');
407
408
            $index = $this->readIndexFromStream($stream);
409
410
            fclose($stream);
411
        }
412
413
        return $index;
414
    }
415
416
    protected function doBuildMergedIndex(Index $localIndex = null, Index $lastLocalIndex = null, Index $remoteIndex = null)
417
    {
418
        $localIndex = $localIndex ?: $this->buildLocalIndex();
419
        $lastLocalIndex = $lastLocalIndex ?: $this->loadLastLocalIndex();
420
        $remoteIndex = $remoteIndex ?: $this->loadRemoteIndex();
421
422
        return $this->getIndexMerger()->merge($localIndex, $lastLocalIndex, $remoteIndex);
423
    }
424
425
    protected function doGetOperationCollection(Index $localIndex = null, Index $remoteIndex = null, Index $mergedIndex = null): OperationCollection
426
    {
427
        $localIndex = $localIndex ?: $this->buildLocalIndex();
428
        $remoteIndex = $remoteIndex ?: $this->loadRemoteIndex();
429
        $mergedIndex = $mergedIndex ?: $this->doBuildMergedIndex($localIndex, $remoteIndex);
430
431
        $uploadStreamFilters = [
432
            'zlib.deflate' => []
433
        ];
434
        $downloadStreamFilters = [
435
            'zlib.inflate' => []
436
        ];
437
438
439
        $operationCollection = new OperationCollection();
440
441
        $directoryMtimes = [];
442
443
        foreach ($mergedIndex as $indexObject)
444
        {
445
            /** @var IndexObject $indexObject */
446
447
            $absoluteLocalPath = $this->localPath . $indexObject->getRelativePath();
448
449
            $localObject = $localIndex->getObjectByPath($indexObject->getRelativePath());
450
            $remoteObject = $remoteIndex ? $remoteIndex->getObjectByPath($indexObject->getRelativePath()) : null;
451
452
453
            if ($localObject !== null && $localObject->getType() !== $indexObject->getType())
454
            {
455
                $operationCollection->addOperation(new UnlinkOperation($absoluteLocalPath));
456
            }
457
458
459
            if ($indexObject->isDirectory())
460
            {
461
                if ($localObject === null)
462
                {
463
                    $operationCollection->addOperation(new MkdirOperation($absoluteLocalPath, $indexObject->getMode()));
464
                }
465
                elseif (!$localObject->isDirectory())
466
                {
467
                    $operationCollection->addOperation(new MkdirOperation($absoluteLocalPath, $indexObject->getMode()));
468
                }
469
470
                if ($localObject !== null && $localObject->isDirectory())
471
                {
472
                    if ($localObject->getMtime() !== $indexObject->getMtime())
473
                    {
474
                        $directoryMtimes[$absoluteLocalPath] = $indexObject->getMtime();
475
                    }
476
                }
477
            }
478
479
            elseif ($indexObject->isFile())
480
            {
481
                // local file did not exist, hasn't been a file before or is outdated
482
                if ($localObject === null || !$localObject->isFile() || $localObject->getMtime() < $indexObject->getMtime())
483
                {
484
                    $operationCollection->addOperation(new DownloadOperation($absoluteLocalPath, $indexObject->getBlobId(), $this->vaultConnection, $downloadStreamFilters));
485
                    $operationCollection->addOperation(new TouchOperation($absoluteLocalPath, $indexObject->getMtime()));
486
                    $operationCollection->addOperation(new ChmodOperation($absoluteLocalPath, $indexObject->getMode()));
487
488
                    $directoryMtimes[dirname($absoluteLocalPath)] = $indexObject->getMtime();
489
                }
490
491
                // local file got updated
492
                elseif ($remoteObject === null || $indexObject->getBlobId() !== $remoteObject->getBlobId())
493
                {
494
                    // generate blob id
495
                    do
496
                    {
497
                        $blobId = $mergedIndex->generateNewBlobId();
498
                    }
499
                    while ($this->vaultConnection->exists($blobId));
500
501
                    $indexObject->setBlobId($blobId);
502
503
                    $operationCollection->addOperation(new UploadOperation($absoluteLocalPath, $indexObject->getBlobId(), $this->vaultConnection, $uploadStreamFilters));
504
                }
505
            }
506
507
            elseif ($indexObject->isLink())
508
            {
509
                $absoluteLinkTarget = dirname($absoluteLocalPath) . DIRECTORY_SEPARATOR . $indexObject->getLinkTarget();
510
511
                if ($localObject !== null && $localObject->getLinkTarget() !== $indexObject->getLinkTarget())
512
                {
513
                    $operationCollection->addOperation(new UnlinkOperation($absoluteLocalPath));
514
                    $operationCollection->addOperation(new SymlinkOperation($absoluteLocalPath, $absoluteLinkTarget, $indexObject->getMode()));
515
                }
516
            }
517
518
            else
519
            {
520
                // unknown object type
521
                throw new Exception();
522
            }
523
524
525
            if ($localObject !== null && $localObject->getMode() !== $indexObject->getMode())
526
            {
527
                $operationCollection->addOperation(new ChmodOperation($absoluteLocalPath, $indexObject->getMode()));
528
            }
529
        }
530
531
        // remove superfluous local files
532
        foreach ($localIndex as $localObject)
533
        {
534
            /** @var IndexObject $localObject */
535
536
            if ($mergedIndex->getObjectByPath($localObject->getRelativePath()) === null)
537
            {
538
                $operationCollection->addOperation(new UnlinkOperation($this->localPath . $localObject->getRelativePath()));
539
            }
540
        }
541
542
        // set directory mtimes
543
        foreach ($directoryMtimes as $absoluteLocalPath => $mtime)
544
        {
545
            $operationCollection->addOperation(new TouchOperation($absoluteLocalPath, $mtime));
546
        }
547
548
        return $operationCollection;
549
    }
550
551
    protected function readIndexFromStream($stream, \DateTime $created = null): Index
552
    {
553
        if (!is_resource($stream))
554
        {
555
            throw new Exception();
556
        }
557
558
        $index = new Index($created);
559
560
        while (($row = fgetcsv($stream)) !== false)
561
        {
562
            $index->addObject(IndexObject::fromIndexRecord($row));
563
        }
564
565
        return $index;
566
    }
567
568 View Code Duplication
    protected function writeIndexToTemporaryFile(Index $index): string
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
569
    {
570
        $path = tempnam(sys_get_temp_dir(), 'index');
571
        $stream = fopen($path, 'wb');
572
573
        foreach ($index as $object)
574
        {
575
            /** @var IndexObject $object */
576
577
            if (fputcsv($stream, $object->getIndexRecord()) === false)
578
            {
579
                throw new Exception();
580
            }
581
        }
582
583
        fclose($stream);
584
585
        return $path;
586
    }
587
588
    protected function readSynchronizationListFromStream($stream): SynchronizationList
589
    {
590
        if (!is_resource($stream))
591
        {
592
            throw new Exception();
593
        }
594
595
        $list = new SynchronizationList();
596
597
        while (($row = fgetcsv($stream)) !== false)
598
        {
599
            $list->addSynchronization(Synchronization::fromRecord($row));
600
        }
601
602
        return $list;
603
    }
604
605 View Code Duplication
    protected function writeSynchronizationListToTemporaryFile(SynchronizationList $synchronizationList): string
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
606
    {
607
        $path = tempnam(sys_get_temp_dir(), 'synchronizationList');
608
        $stream = fopen($path, 'wb');
609
610
        foreach ($synchronizationList as $synchronization)
611
        {
612
            /** @var Synchronization $synchronization */
613
614
            if (fputcsv($stream, $synchronization->getRecord()) === false)
615
            {
616
                throw new Exception();
617
            }
618
        }
619
620
        fclose($stream);
621
622
        return $path;
623
    }
624
625
    protected function generateNewBlobId(): string
626
    {
627
        do
628
        {
629
            $blobId = Uuid::uuid4()->toString();
630
        }
631
        while ($this->vaultConnection->exists($blobId));
632
633
        return $blobId;
634
    }
635
636
    protected function initMetadataDirectory(): string
637
    {
638
        $path = $this->localPath . static::METADATA_DIRECTORY_NAME;
639
640
        if (!is_dir($path))
641
        {
642
            if (!mkdir($path))
643
            {
644
                throw new Exception();
645
            }
646
        }
647
648
        return $path . DIRECTORY_SEPARATOR;
649
    }
650
651
    protected function getLastLocalIndexFilePath(): string
652
    {
653
        return $this->initMetadataDirectory() . sprintf('lastLocalIndex-%s', $this->getTitle());
654
    }
655
}
656