Completed
Push — master ( 0f9475...4a0502 )
by
unknown
14:19
created

PackageManager::scanPackagePathsForExtensions()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 16
nc 4
nop 0
dl 0
loc 28
rs 9.7333
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
 * It is free software; you can redistribute it and/or modify it under
7
 * the terms of the GNU General Public License, either version 2
8
 * of the License, or any later version.
9
 *
10
 * For the full copyright and license information, please read the
11
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
 * The TYPO3 project - inspiring people to share!
14
 */
15
16
namespace TYPO3\CMS\Core\Package;
17
18
use Symfony\Component\Finder\Finder;
19
use Symfony\Component\Finder\SplFileInfo;
20
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
21
use TYPO3\CMS\Core\Core\ClassLoadingInformation;
22
use TYPO3\CMS\Core\Core\Environment;
23
use TYPO3\CMS\Core\Information\Typo3Version;
24
use TYPO3\CMS\Core\Package\Event\PackagesMayHaveChangedEvent;
25
use TYPO3\CMS\Core\Package\Exception\InvalidPackageKeyException;
26
use TYPO3\CMS\Core\Package\Exception\InvalidPackageManifestException;
27
use TYPO3\CMS\Core\Package\Exception\InvalidPackagePathException;
28
use TYPO3\CMS\Core\Package\Exception\InvalidPackageStateException;
29
use TYPO3\CMS\Core\Package\Exception\MissingPackageManifestException;
30
use TYPO3\CMS\Core\Package\Exception\PackageManagerCacheUnavailableException;
31
use TYPO3\CMS\Core\Package\Exception\PackageStatesFileNotWritableException;
32
use TYPO3\CMS\Core\Package\Exception\PackageStatesUnavailableException;
33
use TYPO3\CMS\Core\Package\Exception\ProtectedPackageKeyException;
34
use TYPO3\CMS\Core\Package\Exception\UnknownPackageException;
35
use TYPO3\CMS\Core\Package\MetaData\PackageConstraint;
36
use TYPO3\CMS\Core\Service\DependencyOrderingService;
37
use TYPO3\CMS\Core\Service\OpcodeCacheService;
38
use TYPO3\CMS\Core\SingletonInterface;
39
use TYPO3\CMS\Core\Utility\ArrayUtility;
40
use TYPO3\CMS\Core\Utility\GeneralUtility;
41
use TYPO3\CMS\Core\Utility\PathUtility;
42
43
/**
44
 * The default TYPO3 Package Manager
45
 */
46
class PackageManager implements SingletonInterface
47
{
48
    /**
49
     * @var DependencyOrderingService
50
     */
51
    protected $dependencyOrderingService;
52
53
    /**
54
     * @var FrontendInterface
55
     */
56
    protected $coreCache;
57
58
    /**
59
     * @var string
60
     */
61
    protected $cacheIdentifier;
62
63
    /**
64
     * @var array
65
     */
66
    protected $packagesBasePaths = [];
67
68
    /**
69
     * @var array
70
     */
71
    protected $packageAliasMap = [];
72
73
    /**
74
     * Absolute path leading to the various package directories
75
     * @var string
76
     */
77
    protected $packagesBasePath;
78
79
    /**
80
     * Array of available packages, indexed by package key
81
     * @var PackageInterface[]
82
     */
83
    protected $packages = [];
84
85
    /**
86
     * @var bool
87
     */
88
    protected $availablePackagesScanned = false;
89
90
    /**
91
     * A map between ComposerName and PackageKey, only available when scanAvailablePackages is run
92
     * @var array
93
     */
94
    protected $composerNameToPackageKeyMap = [];
95
96
    /**
97
     * List of active packages as package key => package object
98
     * @var array
99
     */
100
    protected $activePackages = [];
101
102
    /**
103
     * @var string
104
     */
105
    protected $packageStatesPathAndFilename;
106
107
    /**
108
     * Package states configuration as stored in the PackageStates.php file
109
     * @var array
110
     */
111
    protected $packageStatesConfiguration = [];
112
113
    /**
114
     * @param DependencyOrderingService $dependencyOrderingService
115
     */
116
    public function __construct(DependencyOrderingService $dependencyOrderingService)
117
    {
118
        $this->packagesBasePath = Environment::getPublicPath() . '/';
119
        $this->packageStatesPathAndFilename = Environment::getLegacyConfigPath() . '/PackageStates.php';
120
        $this->dependencyOrderingService = $dependencyOrderingService;
121
    }
122
123
    /**
124
     * @param FrontendInterface $coreCache
125
     * @internal
126
     */
127
    public function injectCoreCache(FrontendInterface $coreCache)
128
    {
129
        $this->coreCache = $coreCache;
130
    }
131
132
    /**
133
     * Initializes the package manager
134
     * @internal
135
     */
136
    public function initialize()
137
    {
138
        try {
139
            $this->loadPackageManagerStatesFromCache();
140
        } catch (PackageManagerCacheUnavailableException $exception) {
141
            $this->loadPackageStates();
142
            $this->initializePackageObjects();
143
            $this->saveToPackageCache();
144
        }
145
    }
146
147
    /**
148
     * @internal
149
     * @return string|null
150
     */
151
    public function getCacheIdentifier()
152
    {
153
        if ($this->cacheIdentifier === null) {
154
            $mTime = @filemtime($this->packageStatesPathAndFilename);
155
            if ($mTime !== false) {
156
                $this->cacheIdentifier = md5((string)(new Typo3Version()) . $this->packageStatesPathAndFilename . $mTime);
157
            } else {
158
                $this->cacheIdentifier = null;
159
            }
160
        }
161
        return $this->cacheIdentifier;
162
    }
163
164
    /**
165
     * @return string
166
     */
167
    protected function getCacheEntryIdentifier()
168
    {
169
        $cacheIdentifier = $this->getCacheIdentifier();
170
        return $cacheIdentifier !== null ? 'PackageManager_' . $cacheIdentifier : null;
171
    }
172
173
    /**
174
     * Saves the current state of all relevant information to the TYPO3 Core Cache
175
     */
176
    protected function saveToPackageCache()
177
    {
178
        $cacheEntryIdentifier = $this->getCacheEntryIdentifier();
179
        if ($cacheEntryIdentifier !== null) {
0 ignored issues
show
introduced by
The condition $cacheEntryIdentifier !== null is always true.
Loading history...
180
            // Build cache file
181
            $packageCache = [
182
                'packageStatesConfiguration' => $this->packageStatesConfiguration,
183
                'packageAliasMap' => $this->packageAliasMap,
184
                'composerNameToPackageKeyMap' => $this->composerNameToPackageKeyMap,
185
                'packageObjects' => serialize($this->packages),
186
            ];
187
            $this->coreCache->set(
188
                $cacheEntryIdentifier,
189
                'return ' . PHP_EOL . var_export($packageCache, true) . ';'
190
            );
191
        }
192
    }
193
194
    /**
195
     * Attempts to load the package manager states from cache
196
     *
197
     * @throws Exception\PackageManagerCacheUnavailableException
198
     */
199
    protected function loadPackageManagerStatesFromCache()
200
    {
201
        $cacheEntryIdentifier = $this->getCacheEntryIdentifier();
202
        if ($cacheEntryIdentifier === null || ($packageCache = $this->coreCache->require($cacheEntryIdentifier)) === false) {
0 ignored issues
show
Bug introduced by
The method require() does not exist on TYPO3\CMS\Core\Cache\Frontend\FrontendInterface. It seems like you code against a sub-type of TYPO3\CMS\Core\Cache\Frontend\FrontendInterface such as TYPO3\CMS\Core\Cache\Frontend\PhpFrontend. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

202
        if ($cacheEntryIdentifier === null || ($packageCache = $this->coreCache->/** @scrutinizer ignore-call */ require($cacheEntryIdentifier)) === false) {
Loading history...
203
            throw new PackageManagerCacheUnavailableException('The package state cache could not be loaded.', 1393883342);
204
        }
205
        $this->packageStatesConfiguration = $packageCache['packageStatesConfiguration'];
206
        if ($this->packageStatesConfiguration['version'] < 5) {
207
            throw new PackageManagerCacheUnavailableException('The package state cache could not be loaded.', 1393883341);
208
        }
209
        $this->packageAliasMap = $packageCache['packageAliasMap'];
210
        $this->composerNameToPackageKeyMap = $packageCache['composerNameToPackageKeyMap'];
211
        $this->packages = unserialize($packageCache['packageObjects'], [
212
            'allowed_classes' => [
213
                Package::class,
214
                MetaData::class,
215
                PackageConstraint::class,
216
                \stdClass::class,
217
            ]
218
        ]);
219
    }
220
221
    /**
222
     * Loads the states of available packages from the PackageStates.php file.
223
     * The result is stored in $this->packageStatesConfiguration.
224
     *
225
     * @throws Exception\PackageStatesUnavailableException
226
     */
227
    protected function loadPackageStates()
228
    {
229
        $this->packageStatesConfiguration = @include $this->packageStatesPathAndFilename ?: [];
230
        if (!isset($this->packageStatesConfiguration['version']) || $this->packageStatesConfiguration['version'] < 5) {
231
            throw new PackageStatesUnavailableException('The PackageStates.php file is either corrupt or unavailable.', 1381507733);
232
        }
233
        $this->registerPackagesFromConfiguration($this->packageStatesConfiguration['packages'], false);
234
    }
235
236
    /**
237
     * Initializes activePackages property
238
     *
239
     * Saves PackageStates.php if list of required extensions has changed.
240
     */
241
    protected function initializePackageObjects()
242
    {
243
        $requiredPackages = [];
244
        $activePackages = [];
245
        foreach ($this->packages as $packageKey => $package) {
246
            if ($package->isProtected()) {
247
                $requiredPackages[$packageKey] = $package;
248
            }
249
            if (isset($this->packageStatesConfiguration['packages'][$packageKey])) {
250
                $activePackages[$packageKey] = $package;
251
            }
252
        }
253
        $previousActivePackages = $activePackages;
254
        $activePackages = array_merge($requiredPackages, $activePackages);
255
256
        if ($activePackages != $previousActivePackages) {
257
            foreach ($requiredPackages as $requiredPackageKey => $package) {
258
                $this->registerActivePackage($package);
259
            }
260
            $this->sortAndSavePackageStates();
261
        }
262
    }
263
264
    /**
265
     * @param PackageInterface $package
266
     */
267
    protected function registerActivePackage(PackageInterface $package)
268
    {
269
        // reset the active packages so they are rebuilt.
270
        $this->activePackages = [];
271
        $this->packageStatesConfiguration['packages'][$package->getPackageKey()] = ['packagePath' => str_replace($this->packagesBasePath, '', $package->getPackagePath())];
272
    }
273
274
    /**
275
     * Scans all directories in the packages directories for available packages.
276
     * For each package a Package object is created and stored in $this->packages.
277
     * @internal
278
     */
279
    public function scanAvailablePackages()
280
    {
281
        $packagePaths = $this->scanPackagePathsForExtensions();
282
        $packages = [];
283
        foreach ($packagePaths as $packageKey => $packagePath) {
284
            try {
285
                $composerManifest = $this->getComposerManifest($packagePath);
286
                $packageKey = $this->getPackageKeyFromManifest($composerManifest, $packagePath);
287
                $this->composerNameToPackageKeyMap[strtolower($composerManifest->name)] = $packageKey;
288
                $packages[$packageKey] = ['packagePath' => str_replace($this->packagesBasePath, '', $packagePath)];
289
            } catch (MissingPackageManifestException $exception) {
290
                if (!$this->isPackageKeyValid($packageKey)) {
291
                    continue;
292
                }
293
            } catch (InvalidPackageKeyException $exception) {
294
                continue;
295
            }
296
        }
297
298
        $this->availablePackagesScanned = true;
299
        $registerOnlyNewPackages = !empty($this->packages);
300
        $this->registerPackagesFromConfiguration($packages, $registerOnlyNewPackages);
301
    }
302
303
    /**
304
     * Event listener to retrigger scanning of available packages.
305
     *
306
     * @param PackagesMayHaveChangedEvent $event
307
     */
308
    public function packagesMayHaveChanged(PackagesMayHaveChangedEvent $event): void
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

308
    public function packagesMayHaveChanged(/** @scrutinizer ignore-unused */ PackagesMayHaveChangedEvent $event): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
309
    {
310
        $this->scanAvailablePackages();
311
    }
312
313
    /**
314
     * Fetches all directories from sysext/global/local locations and checks if the extension contains an ext_emconf.php
315
     *
316
     * @return array
317
     */
318
    protected function scanPackagePathsForExtensions()
319
    {
320
        $collectedExtensionPaths = [];
321
        foreach ($this->getPackageBasePaths() as $packageBasePath) {
322
            // Only add the extension if we have an EMCONF and the extension is not yet registered.
323
            // This is crucial in order to allow overriding of system extension by local extensions
324
            // and strongly depends on the order of paths defined in $this->packagesBasePaths.
325
            $finder = new Finder();
326
            $finder
327
                ->name('ext_emconf.php')
328
                ->followLinks()
329
                ->depth(0)
330
                ->ignoreUnreadableDirs()
331
                ->in($packageBasePath);
332
333
            /** @var SplFileInfo $fileInfo */
334
            foreach ($finder as $fileInfo) {
335
                $path = PathUtility::dirname($fileInfo->getPathname());
336
                $extensionName = PathUtility::basename($path);
337
                // Fix Windows backslashes
338
                // we can't use GeneralUtility::fixWindowsFilePath as we have to keep double slashes for Unit Tests (vfs://)
339
                $currentPath = str_replace('\\', '/', $path) . '/';
340
                if (!isset($collectedExtensionPaths[$extensionName])) {
341
                    $collectedExtensionPaths[$extensionName] = $currentPath;
342
                }
343
            }
344
        }
345
        return $collectedExtensionPaths;
346
    }
347
348
    /**
349
     * Requires and registers all packages which were defined in packageStatesConfiguration
350
     *
351
     * @param array $packages
352
     * @param bool $registerOnlyNewPackages
353
     * @throws Exception\InvalidPackageStateException
354
     * @throws Exception\PackageStatesFileNotWritableException
355
     */
356
    protected function registerPackagesFromConfiguration(array $packages, $registerOnlyNewPackages = false)
357
    {
358
        $packageStatesHasChanged = false;
359
        foreach ($packages as $packageKey => $stateConfiguration) {
360
            if ($registerOnlyNewPackages && $this->isPackageRegistered($packageKey)) {
361
                continue;
362
            }
363
364
            if (!isset($stateConfiguration['packagePath'])) {
365
                $this->unregisterPackageByPackageKey($packageKey);
366
                $packageStatesHasChanged = true;
367
                continue;
368
            }
369
370
            try {
371
                $packagePath = PathUtility::sanitizeTrailingSeparator($this->packagesBasePath . $stateConfiguration['packagePath']);
372
                $package = new Package($this, $packageKey, $packagePath);
373
            } catch (InvalidPackagePathException|InvalidPackageKeyException|InvalidPackageManifestException $exception) {
0 ignored issues
show
Coding Style introduced by
Expected 1 space before "|"; 0 found
Loading history...
Coding Style introduced by
Expected 1 space after "|"; 0 found
Loading history...
374
                $this->unregisterPackageByPackageKey($packageKey);
375
                $packageStatesHasChanged = true;
376
                continue;
377
            }
378
379
            $this->registerPackage($package);
380
        }
381
        if ($packageStatesHasChanged) {
382
            $this->sortAndSavePackageStates();
383
        }
384
    }
385
386
    /**
387
     * Register a native TYPO3 package
388
     *
389
     * @param PackageInterface $package The Package to be registered
390
     * @return PackageInterface
391
     * @throws Exception\InvalidPackageStateException
392
     * @internal
393
     */
394
    public function registerPackage(PackageInterface $package)
395
    {
396
        $packageKey = $package->getPackageKey();
397
        if ($this->isPackageRegistered($packageKey)) {
398
            throw new InvalidPackageStateException('Package "' . $packageKey . '" is already registered.', 1338996122);
399
        }
400
401
        $this->packages[$packageKey] = $package;
402
403
        if ($package instanceof PackageInterface) {
0 ignored issues
show
introduced by
$package is always a sub-type of TYPO3\CMS\Core\Package\PackageInterface.
Loading history...
404
            foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) {
405
                $this->packageAliasMap[strtolower($packageToReplace)] = $package->getPackageKey();
406
            }
407
        }
408
        return $package;
409
    }
410
411
    /**
412
     * Unregisters a package from the list of available packages
413
     *
414
     * @param string $packageKey Package Key of the package to be unregistered
415
     */
416
    protected function unregisterPackageByPackageKey($packageKey)
417
    {
418
        try {
419
            $package = $this->getPackage($packageKey);
420
            if ($package instanceof PackageInterface) {
0 ignored issues
show
introduced by
$package is always a sub-type of TYPO3\CMS\Core\Package\PackageInterface.
Loading history...
421
                foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) {
422
                    unset($this->packageAliasMap[strtolower($packageToReplace)]);
423
                }
424
            }
425
        } catch (UnknownPackageException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
426
        }
427
        unset($this->packages[$packageKey]);
428
        unset($this->packageStatesConfiguration['packages'][$packageKey]);
429
    }
430
431
    /**
432
     * Resolves a TYPO3 package key from a composer package name.
433
     *
434
     * @param string $composerName
435
     * @return string
436
     * @internal
437
     */
438
    public function getPackageKeyFromComposerName($composerName)
439
    {
440
        $lowercasedComposerName = strtolower($composerName);
441
        if (isset($this->packageAliasMap[$lowercasedComposerName])) {
442
            return $this->packageAliasMap[$lowercasedComposerName];
443
        }
444
        if (isset($this->composerNameToPackageKeyMap[$lowercasedComposerName])) {
445
            return $this->composerNameToPackageKeyMap[$lowercasedComposerName];
446
        }
447
        return $composerName;
448
    }
449
450
    /**
451
     * Returns a PackageInterface object for the specified package.
452
     * A package is available, if the package directory contains valid MetaData information.
453
     *
454
     * @param string $packageKey
455
     * @return PackageInterface The requested package object
456
     * @throws Exception\UnknownPackageException if the specified package is not known
457
     */
458
    public function getPackage($packageKey)
459
    {
460
        if (!$this->isPackageRegistered($packageKey) && !$this->isPackageAvailable($packageKey)) {
461
            throw new UnknownPackageException('Package "' . $packageKey . '" is not available. Please check if the package exists and that the package key is correct (package keys are case sensitive).', 1166546734);
462
        }
463
        return $this->packages[$packageKey];
464
    }
465
466
    /**
467
     * Returns TRUE if a package is available (the package's files exist in the packages directory)
468
     * or FALSE if it's not. If a package is available it doesn't mean necessarily that it's active!
469
     *
470
     * @param string $packageKey The key of the package to check
471
     * @return bool TRUE if the package is available, otherwise FALSE
472
     */
473
    public function isPackageAvailable($packageKey)
474
    {
475
        if ($this->isPackageRegistered($packageKey)) {
476
            return true;
477
        }
478
479
        // If activePackages is empty, the PackageManager is currently initializing
480
        // thus packages should not be scanned
481
        if (!$this->availablePackagesScanned && !empty($this->activePackages)) {
482
            $this->scanAvailablePackages();
483
        }
484
485
        return $this->isPackageRegistered($packageKey);
486
    }
487
488
    /**
489
     * Returns TRUE if a package is activated or FALSE if it's not.
490
     *
491
     * @param string $packageKey The key of the package to check
492
     * @return bool TRUE if package is active, otherwise FALSE
493
     */
494
    public function isPackageActive($packageKey)
495
    {
496
        $packageKey = $this->getPackageKeyFromComposerName($packageKey);
497
498
        return isset($this->packageStatesConfiguration['packages'][$packageKey]);
499
    }
500
501
    /**
502
     * Deactivates a package and updates the packagestates configuration
503
     *
504
     * @param string $packageKey
505
     * @throws Exception\PackageStatesFileNotWritableException
506
     * @throws Exception\ProtectedPackageKeyException
507
     * @throws Exception\UnknownPackageException
508
     * @internal
509
     */
510
    public function deactivatePackage($packageKey)
511
    {
512
        $packagesWithDependencies = $this->sortActivePackagesByDependencies();
513
514
        foreach ($packagesWithDependencies as $packageStateKey => $packageStateConfiguration) {
515
            if ($packageKey === $packageStateKey || empty($packageStateConfiguration['dependencies'])) {
516
                continue;
517
            }
518
            if (in_array($packageKey, $packageStateConfiguration['dependencies'], true)) {
519
                $this->deactivatePackage($packageStateKey);
520
            }
521
        }
522
523
        if (!$this->isPackageActive($packageKey)) {
524
            return;
525
        }
526
527
        $package = $this->getPackage($packageKey);
528
        if ($package->isProtected()) {
529
            throw new ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be deactivated.', 1308662891);
530
        }
531
532
        $this->activePackages = [];
533
        unset($this->packageStatesConfiguration['packages'][$packageKey]);
534
        $this->sortAndSavePackageStates();
535
    }
536
537
    /**
538
     * @param string $packageKey
539
     * @internal
540
     */
541
    public function activatePackage($packageKey)
542
    {
543
        $package = $this->getPackage($packageKey);
544
        $this->registerTransientClassLoadingInformationForPackage($package);
545
546
        if ($this->isPackageActive($packageKey)) {
547
            return;
548
        }
549
550
        $this->registerActivePackage($package);
551
        $this->sortAndSavePackageStates();
552
    }
553
554
    /**
555
     * @param PackageInterface $package
556
     * @throws \TYPO3\CMS\Core\Exception
557
     */
558
    protected function registerTransientClassLoadingInformationForPackage(PackageInterface $package)
559
    {
560
        if (Environment::isComposerMode()) {
561
            return;
562
        }
563
        ClassLoadingInformation::registerTransientClassLoadingInformationForPackage($package);
564
    }
565
566
    /**
567
     * Removes a package from the file system.
568
     *
569
     * @param string $packageKey
570
     * @throws Exception
571
     * @throws Exception\ProtectedPackageKeyException
572
     * @throws Exception\UnknownPackageException
573
     * @internal
574
     */
575
    public function deletePackage($packageKey)
576
    {
577
        if (!$this->isPackageAvailable($packageKey)) {
578
            throw new UnknownPackageException('Package "' . $packageKey . '" is not available and cannot be removed.', 1166543253);
579
        }
580
581
        $package = $this->getPackage($packageKey);
582
        if ($package->isProtected()) {
583
            throw new ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be removed.', 1220722120);
584
        }
585
586
        if ($this->isPackageActive($packageKey)) {
587
            $this->deactivatePackage($packageKey);
588
        }
589
590
        $this->unregisterPackage($package);
591
        $this->sortAndSavePackageStates();
592
593
        $packagePath = $package->getPackagePath();
594
        $deletion = GeneralUtility::rmdir($packagePath, true);
595
        if ($deletion === false) {
596
            throw new Exception('Please check file permissions. The directory "' . $packagePath . '" for package "' . $packageKey . '" could not be removed.', 1301491089);
597
        }
598
    }
599
600
    /**
601
     * Returns an array of \TYPO3\CMS\Core\Package objects of all active packages.
602
     * A package is active, if it is available and has been activated in the package
603
     * manager settings.
604
     *
605
     * @return PackageInterface[]
606
     */
607
    public function getActivePackages()
608
    {
609
        if (empty($this->activePackages)) {
610
            if (!empty($this->packageStatesConfiguration['packages'])) {
611
                foreach ($this->packageStatesConfiguration['packages'] as $packageKey => $packageConfig) {
612
                    $this->activePackages[$packageKey] = $this->getPackage($packageKey);
613
                }
614
            }
615
        }
616
        return $this->activePackages;
617
    }
618
619
    /**
620
     * Returns TRUE if a package was already registered or FALSE if it's not.
621
     *
622
     * @param string $packageKey
623
     * @return bool
624
     */
625
    protected function isPackageRegistered($packageKey)
626
    {
627
        $packageKey = $this->getPackageKeyFromComposerName($packageKey);
628
629
        return isset($this->packages[$packageKey]);
630
    }
631
632
    /**
633
     * Orders all active packages by comparing their dependencies. By this, the packages
634
     * and package configurations arrays holds all packages in the correct
635
     * initialization order.
636
     *
637
     * @return array
638
     */
639
    protected function sortActivePackagesByDependencies()
640
    {
641
        $packagesWithDependencies = $this->resolvePackageDependencies($this->packageStatesConfiguration['packages']);
642
643
        // sort the packages by key at first, so we get a stable sorting of "equivalent" packages afterwards
644
        ksort($packagesWithDependencies);
645
        $sortedPackageKeys = $this->sortPackageStatesConfigurationByDependency($packagesWithDependencies);
646
647
        // Reorder the packages according to the loading order
648
        $this->packageStatesConfiguration['packages'] = [];
649
        foreach ($sortedPackageKeys as $packageKey) {
650
            $this->registerActivePackage($this->packages[$packageKey]);
651
        }
652
        return $packagesWithDependencies;
653
    }
654
655
    /**
656
     * Resolves the dependent packages from the meta data of all packages recursively. The
657
     * resolved direct or indirect dependencies of each package will put into the package
658
     * states configuration array.
659
     *
660
     * @param array $packageConfig
661
     * @return array
662
     */
663
    protected function resolvePackageDependencies($packageConfig)
664
    {
665
        $packagesWithDependencies = [];
666
        foreach ($packageConfig as $packageKey => $_) {
667
            $packagesWithDependencies[$packageKey]['dependencies'] = $this->getDependencyArrayForPackage($packageKey);
668
            $packagesWithDependencies[$packageKey]['suggestions'] = $this->getSuggestionArrayForPackage($packageKey);
669
        }
670
        return $packagesWithDependencies;
671
    }
672
673
    /**
674
     * Returns an array of suggested package keys for the given package.
675
     *
676
     * @param string $packageKey The package key to fetch the suggestions for
677
     * @return array|null An array of directly suggested packages
678
     */
679
    protected function getSuggestionArrayForPackage($packageKey)
680
    {
681
        if (!isset($this->packages[$packageKey])) {
682
            return null;
683
        }
684
        $suggestedPackageKeys = [];
685
        $suggestedPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_SUGGESTS);
686
        foreach ($suggestedPackageConstraints as $constraint) {
687
            if ($constraint instanceof PackageConstraint) {
688
                $suggestedPackageKey = $constraint->getValue();
689
                if (isset($this->packages[$suggestedPackageKey])) {
690
                    $suggestedPackageKeys[] = $suggestedPackageKey;
691
                }
692
            }
693
        }
694
        return array_reverse($suggestedPackageKeys);
695
    }
696
697
    /**
698
     * Saves the current content of $this->packageStatesConfiguration to the
699
     * PackageStates.php file.
700
     *
701
     * @throws Exception\PackageStatesFileNotWritableException
702
     */
703
    protected function savePackageStates()
704
    {
705
        $this->packageStatesConfiguration['version'] = 5;
706
707
        $fileDescription = "# PackageStates.php\n\n";
708
        $fileDescription .= "# This file is maintained by TYPO3's package management. Although you can edit it\n";
709
        $fileDescription .= "# manually, you should rather use the extension manager for maintaining packages.\n";
710
        $fileDescription .= "# This file will be regenerated automatically if it doesn't exist. Deleting this file\n";
711
        $fileDescription .= "# should, however, never become necessary if you use the package commands.\n";
712
713
        if (!@is_writable($this->packageStatesPathAndFilename)) {
714
            // If file does not exist, try to create it
715
            $fileHandle = @fopen($this->packageStatesPathAndFilename, 'x');
716
            if (!$fileHandle) {
0 ignored issues
show
introduced by
$fileHandle is of type false|resource, thus it always evaluated to false.
Loading history...
717
                throw new PackageStatesFileNotWritableException(
718
                    sprintf('We could not update the list of installed packages because the file %s is not writable. Please, check the file system permissions for this file and make sure that the web server can update it.', $this->packageStatesPathAndFilename),
719
                    1382449759
720
                );
721
            }
722
            fclose($fileHandle);
723
        }
724
        $packageStatesCode = "<?php\n$fileDescription\nreturn " . ArrayUtility::arrayExport($this->packageStatesConfiguration) . ";\n";
0 ignored issues
show
Coding Style Best Practice introduced by
As per coding-style, please use concatenation or sprintf for the variable $fileDescription instead of interpolation.

It is generally a best practice as it is often more readable to use concatenation instead of interpolation for variables inside strings.

// Instead of
$x = "foo $bar $baz";

// Better use either
$x = "foo " . $bar . " " . $baz;
$x = sprintf("foo %s %s", $bar, $baz);
Loading history...
725
        GeneralUtility::writeFile($this->packageStatesPathAndFilename, $packageStatesCode, true);
726
        // Cache identifier depends on package states file, therefore we invalidate the identifier
727
        $this->cacheIdentifier = null;
728
729
        GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($this->packageStatesPathAndFilename);
730
    }
731
732
    /**
733
     * Saves the current content of $this->packageStatesConfiguration to the
734
     * PackageStates.php file.
735
     *
736
     * @throws Exception\PackageStatesFileNotWritableException
737
     */
738
    protected function sortAndSavePackageStates()
739
    {
740
        $this->sortActivePackagesByDependencies();
741
        $this->savePackageStates();
742
    }
743
744
    /**
745
     * Check the conformance of the given package key
746
     *
747
     * @param string $packageKey The package key to validate
748
     * @return bool If the package key is valid, returns TRUE otherwise FALSE
749
     */
750
    public function isPackageKeyValid($packageKey)
751
    {
752
        return preg_match(PackageInterface::PATTERN_MATCH_PACKAGEKEY, $packageKey) === 1 || preg_match(PackageInterface::PATTERN_MATCH_EXTENSIONKEY, $packageKey) === 1;
753
    }
754
755
    /**
756
     * Returns an array of \TYPO3\CMS\Core\Package objects of all available packages.
757
     * A package is available, if the package directory contains valid meta information.
758
     *
759
     * @return PackageInterface[] Array of PackageInterface
760
     */
761
    public function getAvailablePackages()
762
    {
763
        if ($this->availablePackagesScanned === false) {
764
            $this->scanAvailablePackages();
765
        }
766
767
        return $this->packages;
768
    }
769
770
    /**
771
     * Unregisters a package from the list of available packages
772
     *
773
     * @param PackageInterface $package The package to be unregistered
774
     * @throws Exception\InvalidPackageStateException
775
     * @internal
776
     */
777
    public function unregisterPackage(PackageInterface $package)
778
    {
779
        $packageKey = $package->getPackageKey();
780
        if (!$this->isPackageRegistered($packageKey)) {
781
            throw new InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1338996142);
782
        }
783
        $this->unregisterPackageByPackageKey($packageKey);
784
    }
785
786
    /**
787
     * Reloads a package and its information
788
     *
789
     * @param string $packageKey
790
     * @throws Exception\InvalidPackageStateException if the package isn't available
791
     * @internal
792
     */
793
    public function reloadPackageInformation($packageKey)
794
    {
795
        if (!$this->isPackageRegistered($packageKey)) {
796
            throw new InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1436201329);
797
        }
798
799
        /** @var PackageInterface $package */
800
        $package = $this->packages[$packageKey];
801
        $packagePath = $package->getPackagePath();
802
        $newPackage = new Package($this, $packageKey, $packagePath);
803
        $this->packages[$packageKey] = $newPackage;
804
        unset($package);
805
    }
806
807
    /**
808
     * Returns contents of Composer manifest as a stdObject
809
     *
810
     * @param string $manifestPath
811
     * @return \stdClass
812
     * @throws Exception\InvalidPackageManifestException
813
     * @internal
814
     */
815
    public function getComposerManifest($manifestPath)
816
    {
817
        $composerManifest = null;
818
        if (file_exists($manifestPath . 'composer.json')) {
819
            $json = file_get_contents($manifestPath . 'composer.json');
820
            $composerManifest = json_decode($json);
821
            if (!$composerManifest instanceof \stdClass) {
822
                throw new InvalidPackageManifestException('The composer.json found for extension "' . PathUtility::basename($manifestPath) . '" is invalid!', 1439555561);
823
            }
824
        }
825
826
        $extensionManagerConfiguration = $this->getExtensionEmConf($manifestPath);
827
        $composerManifest = $this->mapExtensionManagerConfigurationToComposerManifest(
828
            PathUtility::basename($manifestPath),
829
            $extensionManagerConfiguration,
830
            $composerManifest ?: new \stdClass()
831
        );
832
833
        return $composerManifest;
834
    }
835
836
    /**
837
     * Fetches MetaData information from ext_emconf.php, used for
838
     * resolving dependencies as well.
839
     *
840
     * @param string $packagePath
841
     * @return array
842
     * @throws Exception\InvalidPackageManifestException
843
     */
844
    protected function getExtensionEmConf($packagePath)
845
    {
846
        $packageKey = PathUtility::basename($packagePath);
847
        $_EXTKEY = $packageKey;
848
        $path = $packagePath . 'ext_emconf.php';
849
        $EM_CONF = null;
850
        if (@file_exists($path)) {
851
            include $path;
852
            if (is_array($EM_CONF[$_EXTKEY])) {
853
                return $EM_CONF[$_EXTKEY];
854
            }
855
        }
856
        throw new InvalidPackageManifestException('No valid ext_emconf.php file found for package "' . $packageKey . '".', 1360403545);
857
    }
858
859
    /**
860
     * Fetches information from ext_emconf.php and maps it so it is treated as it would come from composer.json
861
     *
862
     * @param string $packageKey
863
     * @param array $extensionManagerConfiguration
864
     * @param \stdClass $composerManifest
865
     * @return \stdClass
866
     * @throws Exception\InvalidPackageManifestException
867
     */
868
    protected function mapExtensionManagerConfigurationToComposerManifest($packageKey, array $extensionManagerConfiguration, \stdClass $composerManifest)
869
    {
870
        $this->setComposerManifestValueIfEmpty($composerManifest, 'name', $packageKey);
871
        $this->setComposerManifestValueIfEmpty($composerManifest, 'type', 'typo3-cms-extension');
872
        $this->setComposerManifestValueIfEmpty($composerManifest, 'description', $extensionManagerConfiguration['title'] ?? '');
873
        $this->setComposerManifestValueIfEmpty($composerManifest, 'authors', [['name' => $extensionManagerConfiguration['author'] ?? '', 'email' => $extensionManagerConfiguration['author_email'] ?? '']]);
874
        $composerManifest->version = $extensionManagerConfiguration['version'] ?? '';
875
        if (isset($extensionManagerConfiguration['constraints']['depends']) && is_array($extensionManagerConfiguration['constraints']['depends'])) {
876
            $composerManifest->require = new \stdClass();
877
            foreach ($extensionManagerConfiguration['constraints']['depends'] as $requiredPackageKey => $requiredPackageVersion) {
878
                if (!empty($requiredPackageKey)) {
879
                    if ($requiredPackageKey === 'typo3') {
880
                        // Add implicit dependency to 'core'
881
                        $composerManifest->require->core = $requiredPackageVersion;
882
                    } elseif ($requiredPackageKey !== 'php') {
883
                        // Skip php dependency
884
                        $composerManifest->require->{$requiredPackageKey} = $requiredPackageVersion;
885
                    }
886
                } else {
887
                    throw new InvalidPackageManifestException(sprintf('The extension "%s" has invalid version constraints in depends section. Extension key is missing!', $packageKey), 1439552058);
888
                }
889
            }
890
        }
891
        if (isset($extensionManagerConfiguration['constraints']['conflicts']) && is_array($extensionManagerConfiguration['constraints']['conflicts'])) {
892
            $composerManifest->conflict = new \stdClass();
893
            foreach ($extensionManagerConfiguration['constraints']['conflicts'] as $conflictingPackageKey => $conflictingPackageVersion) {
894
                if (!empty($conflictingPackageKey)) {
895
                    $composerManifest->conflict->$conflictingPackageKey = $conflictingPackageVersion;
896
                } else {
897
                    throw new InvalidPackageManifestException(sprintf('The extension "%s" has invalid version constraints in conflicts section. Extension key is missing!', $packageKey), 1439552059);
898
                }
899
            }
900
        }
901
        if (isset($extensionManagerConfiguration['constraints']['suggests']) && is_array($extensionManagerConfiguration['constraints']['suggests'])) {
902
            $composerManifest->suggest = new \stdClass();
903
            foreach ($extensionManagerConfiguration['constraints']['suggests'] as $suggestedPackageKey => $suggestedPackageVersion) {
904
                if (!empty($suggestedPackageKey)) {
905
                    $composerManifest->suggest->$suggestedPackageKey = $suggestedPackageVersion;
906
                } else {
907
                    throw new InvalidPackageManifestException(sprintf('The extension "%s" has invalid version constraints in suggests section. Extension key is missing!', $packageKey), 1439552060);
908
                }
909
            }
910
        }
911
        if (isset($extensionManagerConfiguration['autoload'])) {
912
            $composerManifest->autoload = json_decode(json_encode($extensionManagerConfiguration['autoload']));
913
        }
914
        // composer.json autoload-dev information must be discarded, as it may contain information only available after a composer install
915
        unset($composerManifest->{'autoload-dev'});
916
        if (isset($extensionManagerConfiguration['autoload-dev'])) {
917
            $composerManifest->{'autoload-dev'} = json_decode(json_encode($extensionManagerConfiguration['autoload-dev']));
918
        }
919
920
        return $composerManifest;
921
    }
922
923
    /**
924
     * @param \stdClass $manifest
925
     * @param string $property
926
     * @param mixed $value
927
     * @return \stdClass
928
     */
929
    protected function setComposerManifestValueIfEmpty(\stdClass $manifest, $property, $value)
930
    {
931
        if (empty($manifest->{$property})) {
932
            $manifest->{$property} = $value;
933
        }
934
935
        return $manifest;
936
    }
937
938
    /**
939
     * Returns an array of dependent package keys for the given package. It will
940
     * do this recursively, so dependencies of dependent packages will also be
941
     * in the result.
942
     *
943
     * @param string $packageKey The package key to fetch the dependencies for
944
     * @param array $dependentPackageKeys
945
     * @param array $trace An array of already visited package keys, to detect circular dependencies
946
     * @return array|null An array of direct or indirect dependent packages
947
     * @throws Exception\InvalidPackageKeyException
948
     */
949
    protected function getDependencyArrayForPackage($packageKey, array &$dependentPackageKeys = [], array $trace = [])
950
    {
951
        if (!isset($this->packages[$packageKey])) {
952
            return null;
953
        }
954
        if (in_array($packageKey, $trace, true) !== false) {
955
            return $dependentPackageKeys;
956
        }
957
        $trace[] = $packageKey;
958
        $dependentPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_DEPENDS);
959
        foreach ($dependentPackageConstraints as $constraint) {
960
            if ($constraint instanceof PackageConstraint) {
961
                $dependentPackageKey = $constraint->getValue();
962
                if (in_array($dependentPackageKey, $dependentPackageKeys, true) === false && in_array($dependentPackageKey, $trace, true) === false) {
963
                    $dependentPackageKeys[] = $dependentPackageKey;
964
                }
965
                $this->getDependencyArrayForPackage($dependentPackageKey, $dependentPackageKeys, $trace);
966
            }
967
        }
968
        return array_reverse($dependentPackageKeys);
969
    }
970
971
    /**
972
     * Resolves package key from Composer manifest
973
     *
974
     * If it is a TYPO3 package the name of the containing directory will be used.
975
     *
976
     * Else if the composer name of the package matches the first part of the lowercased namespace of the package, the mixed
977
     * case version of the composer name / namespace will be used, with backslashes replaced by dots.
978
     *
979
     * Else the composer name will be used with the slash replaced by a dot
980
     *
981
     * @param object $manifest
982
     * @param string $packagePath
983
     * @throws Exception\InvalidPackageManifestException
984
     * @return string
985
     */
986
    protected function getPackageKeyFromManifest($manifest, $packagePath)
987
    {
988
        if (!is_object($manifest)) {
989
            throw new InvalidPackageManifestException('Invalid composer manifest in package path: ' . $packagePath, 1348146451);
990
        }
991
        if (isset($manifest->type) && strpos($manifest->type, 'typo3-cms-') === 0) {
992
            $packageKey = PathUtility::basename($packagePath);
993
            return preg_replace('/[^A-Za-z0-9._-]/', '', $packageKey);
994
        }
995
        $packageKey = str_replace('/', '.', $manifest->name);
996
        return preg_replace('/[^A-Za-z0-9.]/', '', $packageKey);
997
    }
998
999
    /**
1000
     * The order of paths is crucial for allowing overriding of system extension by local extensions.
1001
     * Pay attention if you change order of the paths here.
1002
     *
1003
     * @return array
1004
     */
1005
    protected function getPackageBasePaths()
1006
    {
1007
        if (count($this->packagesBasePaths) < 3) {
1008
            // Check if the directory even exists and if it is not empty
1009
            if (is_dir(Environment::getExtensionsPath()) && $this->hasSubDirectories(Environment::getExtensionsPath())) {
1010
                $this->packagesBasePaths['local'] = Environment::getExtensionsPath() . '/*/';
1011
            }
1012
            if (is_dir(Environment::getBackendPath() . '/ext') && $this->hasSubDirectories(Environment::getBackendPath() . '/ext')) {
1013
                $this->packagesBasePaths['global'] = Environment::getBackendPath() . '/ext/*/';
1014
            }
1015
            $this->packagesBasePaths['system'] = Environment::getFrameworkBasePath() . '/*/';
1016
        }
1017
        return $this->packagesBasePaths;
1018
    }
1019
1020
    /**
1021
     * Returns true if the given path has valid subdirectories, false otherwise.
1022
     *
1023
     * @param string $path
1024
     * @return bool
1025
     */
1026
    protected function hasSubDirectories(string $path): bool
1027
    {
1028
        return !empty(glob(rtrim($path, '/\\') . '/*', GLOB_ONLYDIR));
1029
    }
1030
1031
    /**
1032
     * @param array $packageStatesConfiguration
1033
     * @return array Returns the packageStatesConfiguration sorted by dependencies
1034
     * @throws \UnexpectedValueException
1035
     */
1036
    protected function sortPackageStatesConfigurationByDependency(array $packageStatesConfiguration)
1037
    {
1038
        return $this->dependencyOrderingService->calculateOrder($this->buildDependencyGraph($packageStatesConfiguration));
1039
    }
1040
1041
    /**
1042
     * Convert the package configuration into a dependency definition
1043
     *
1044
     * This converts "dependencies" and "suggestions" to "after" syntax for the usage in DependencyOrderingService
1045
     *
1046
     * @param array $packageStatesConfiguration
1047
     * @param array $packageKeys
1048
     * @return array
1049
     * @throws \UnexpectedValueException
1050
     */
1051
    protected function convertConfigurationForGraph(array $packageStatesConfiguration, array $packageKeys)
1052
    {
1053
        $dependencies = [];
1054
        foreach ($packageKeys as $packageKey) {
1055
            if (!isset($packageStatesConfiguration[$packageKey]['dependencies']) && !isset($packageStatesConfiguration[$packageKey]['suggestions'])) {
1056
                continue;
1057
            }
1058
            $dependencies[$packageKey] = [
1059
                'after' => []
1060
            ];
1061
            if (isset($packageStatesConfiguration[$packageKey]['dependencies'])) {
1062
                foreach ($packageStatesConfiguration[$packageKey]['dependencies'] as $dependentPackageKey) {
1063
                    if (!in_array($dependentPackageKey, $packageKeys, true)) {
1064
                        throw new \UnexpectedValueException(
1065
                            'The package "' . $packageKey . '" depends on "'
1066
                            . $dependentPackageKey . '" which is not present in the system.',
1067
                            1519931815
1068
                        );
1069
                    }
1070
                    $dependencies[$packageKey]['after'][] = $dependentPackageKey;
1071
                }
1072
            }
1073
            if (isset($packageStatesConfiguration[$packageKey]['suggestions'])) {
1074
                foreach ($packageStatesConfiguration[$packageKey]['suggestions'] as $suggestedPackageKey) {
1075
                    // skip suggestions on not existing packages
1076
                    if (in_array($suggestedPackageKey, $packageKeys, true)) {
1077
                        // Suggestions actually have never been meant to influence loading order.
1078
                        // We misuse this currently, as there is no other way to influence the loading order
1079
                        // for not-required packages (soft-dependency).
1080
                        // When considering suggestions for the loading order, we might create a cyclic dependency
1081
                        // if the suggested package already has a real dependency on this package, so the suggestion
1082
                        // has do be dropped in this case and must *not* be taken into account for loading order evaluation.
1083
                        $dependencies[$packageKey]['after-resilient'][] = $suggestedPackageKey;
1084
                    }
1085
                }
1086
            }
1087
        }
1088
        return $dependencies;
1089
    }
1090
1091
    /**
1092
     * Adds all root packages of current dependency graph as dependency to all extensions
1093
     *
1094
     * This ensures that the framework extensions (aka sysext) are
1095
     * always loaded first, before any other external extension.
1096
     *
1097
     * @param array $packageStateConfiguration
1098
     * @param array $rootPackageKeys
1099
     * @return array
1100
     */
1101
    protected function addDependencyToFrameworkToAllExtensions(array $packageStateConfiguration, array $rootPackageKeys)
1102
    {
1103
        $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration);
1104
        $extensionPackageKeys = array_diff(array_keys($packageStateConfiguration), $frameworkPackageKeys);
1105
        foreach ($extensionPackageKeys as $packageKey) {
1106
            // Remove framework packages from list
1107
            $packageKeysWithoutFramework = array_diff(
1108
                $packageStateConfiguration[$packageKey]['dependencies'],
1109
                $frameworkPackageKeys
1110
            );
1111
            // The order of the array_merge is crucial here,
1112
            // we want the framework first
1113
            $packageStateConfiguration[$packageKey]['dependencies'] = array_merge(
1114
                $rootPackageKeys,
1115
                $packageKeysWithoutFramework
1116
            );
1117
        }
1118
        return $packageStateConfiguration;
1119
    }
1120
1121
    /**
1122
     * Builds the dependency graph for all packages
1123
     *
1124
     * This method also introduces dependencies among the dependencies
1125
     * to ensure the loading order is exactly as specified in the list.
1126
     *
1127
     * @param array $packageStateConfiguration
1128
     * @return array
1129
     */
1130
    protected function buildDependencyGraph(array $packageStateConfiguration)
1131
    {
1132
        $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration);
1133
        $frameworkPackagesDependencyGraph = $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $frameworkPackageKeys));
1134
        $packageStateConfiguration = $this->addDependencyToFrameworkToAllExtensions($packageStateConfiguration, $this->dependencyOrderingService->findRootIds($frameworkPackagesDependencyGraph));
1135
1136
        $packageKeys = array_keys($packageStateConfiguration);
1137
        return $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $packageKeys));
1138
    }
1139
1140
    /**
1141
     * @param array $packageStateConfiguration
1142
     * @return array
1143
     */
1144
    protected function findFrameworkPackages(array $packageStateConfiguration)
1145
    {
1146
        $frameworkPackageKeys = [];
1147
        foreach ($packageStateConfiguration as $packageKey => $packageConfiguration) {
1148
            $package = $this->getPackage($packageKey);
1149
            if ($package->getValueFromComposerManifest('type') === 'typo3-cms-framework') {
1150
                $frameworkPackageKeys[] = $packageKey;
1151
            }
1152
        }
1153
1154
        return $frameworkPackageKeys;
1155
    }
1156
}
1157