, suggestions: list} * @phpstan-type StateConfiguration array{packagePath?: non-empty-string} * @phpstan-type StatesConfiguration array{packages?: array, version?: int} */ class PackageManager implements SingletonInterface { /** * @var DependencyOrderingService */ protected $dependencyOrderingService; /** * @var PackageCacheInterface */ protected $packageCache; /** * @var array{local?: non-empty-string, system?: non-empty-string} */ protected $packagesBasePaths = []; /** * @var array */ protected $packageAliasMap = []; /** * Absolute path leading to the various package directories * @var string */ protected $packagesBasePath; /** * Array of available packages, indexed by package key * @var array */ protected $packages = []; /** * @var bool */ protected $availablePackagesScanned = false; /** * A map between ComposerName and PackageKey, only available when scanAvailablePackages is run, * e.g. `['typo3/cms-core' => 'core', 'typo3/cms-backend' => 'backend']` * @var array */ protected $composerNameToPackageKeyMap = []; /** * List of active packages as package key => package object * @var array */ protected $activePackages = []; /** * @var string */ protected $packageStatesPathAndFilename; /** * Package states configuration as stored in the PackageStates.php file * @var StatesConfiguration */ protected $packageStatesConfiguration = []; /** * The regex to match paths with EXT:{package/key} */ protected ?string $packagePathMatchRegex; private array $frameworkPackageNames = []; private array $installedPackageNames = []; public function __construct(DependencyOrderingService $dependencyOrderingService, ?string $packageStatesPathAndFilename = null, ?string $packagesBasePath = null) { $this->dependencyOrderingService = $dependencyOrderingService; $this->packagesBasePath = $packagesBasePath ?? Environment::getProjectPath() . '/'; $this->packageStatesPathAndFilename = $packageStatesPathAndFilename ?? Environment::getPackageStatesFile(); } /** * Access the composer.json of typo3/cms package * to find out all framework package names, so that we * can safely assume this is not a composer dependency */ private function populateFrameworkAndComposerPackageNames(): void { $this->frameworkPackageNames = require __DIR__ . '/../../Resources/Private/Php/framework-packages.php'; $this->installedPackageNames = array_filter( InstalledVersions::getInstalledPackages(), fn(string $packageName) => !in_array($packageName, $this->frameworkPackageNames, true) && $packageName !== 'typo3/cms', ); } /** * @internal */ public function setPackageCache(PackageCacheInterface $packageCache) { $this->packageCache = $packageCache; } /** * Initializes the package manager * @internal */ public function initialize() { try { $this->loadPackageManagerStatesFromCache(); } catch (PackageManagerCacheUnavailableException) { $this->loadPackageStates(); $this->initializePackageObjects(); $appPackage = new VirtualAppPackage( $this, Environment::getProjectPath() . '/', '', ); $this->registerPackage($appPackage); $this->registerActivePackage($appPackage); $this->validateResources(); $this->saveToPackageCache(); } } /** * @internal * @return string|null */ public function getCacheIdentifier() { try { return $this->packageCache->getIdentifier(); } catch (PackageManagerCacheUnavailableException) { return null; } } /** * Saves the current state of all relevant information in the package cache */ protected function saveToPackageCache(): void { // Build cache entry $cacheEntry = PackageCacheEntry::fromPackageData( $this->packageStatesConfiguration, $this->packageAliasMap, $this->composerNameToPackageKeyMap, $this->packages, ); $this->packageCache->store($cacheEntry); } /** * @todo this does not belong here and should be extracted into a different service */ protected function validateResources(): void { $publicPrefixes = []; foreach ($this->packages as $package) { if (!$package instanceof Package) { continue; } $packageKey = $package->getPackageKey(); $resourcePaths = []; foreach ($package->getResources()->getPublicResourceDefinitions() as $resource) { $relativePath = $resource->getRelativePath(); if ( str_starts_with($relativePath, '/') || str_ends_with($relativePath, '/') || !GeneralUtility::validPathStr($relativePath) ) { throw new \RuntimeException(sprintf( 'Invalid relative path "%s" defined by package "%s". Relative paths must not have leading, or trailing slashes, or backpaths (../), or other malicious characters.', $relativePath, $packageKey, ), 1774612899); } if (isset($resourcePaths[$relativePath])) { throw new \RuntimeException(sprintf( 'Relative path "%s" is already defined by package "%s". Resource definitions must be unique.', $relativePath, $packageKey, ), 1774612999); } $resourcePaths[$relativePath] = true; $publicPrefix = $resource->getPublicPrefix(); if (!is_string($publicPrefix)) { continue; } if (isset($publicPrefixes[$publicPrefix])) { throw new \RuntimeException(sprintf( 'Public prefix "%s" is already defined by package "%s" can not be redefined by package "%s"', $publicPrefix, $publicPrefixes[$publicPrefix], $packageKey, ), 1774612898); } $publicPrefixes[$publicPrefix] = $packageKey; } } } /** * Attempts to load the package manager states from cache * * @throws PackageManagerCacheUnavailableException */ protected function loadPackageManagerStatesFromCache() { $cacheEntry = $this->packageCache->fetch(); $this->packageStatesConfiguration = $cacheEntry->getConfiguration(); $this->packageAliasMap = $cacheEntry->getAliasMap(); $this->composerNameToPackageKeyMap = $cacheEntry->getComposerNameMap(); $this->packages = $cacheEntry->getPackages(); } /** * Loads the states of available packages from the PackageStates.php file. * The result is stored in $this->packageStatesConfiguration. * * @throws Exception\PackageStatesUnavailableException */ protected function loadPackageStates() { $this->packageStatesConfiguration = (@include $this->packageStatesPathAndFilename) ?: []; PackageCacheEntry::ensureValidPackageConfiguration($this->packageStatesConfiguration); $this->registerPackagesFromConfiguration($this->packageStatesConfiguration['packages'], false); } /** * Initializes activePackages property * * Saves PackageStates.php if list of required extensions has changed. */ protected function initializePackageObjects() { $requiredPackages = []; $activePackages = []; foreach ($this->packages as $packageKey => $package) { if ($package->isProtected()) { $requiredPackages[$packageKey] = $package; } if (isset($this->packageStatesConfiguration['packages'][$packageKey])) { $activePackages[$packageKey] = $package; } } $previousActivePackages = $activePackages; $activePackages = array_merge($requiredPackages, $activePackages); if ($activePackages != $previousActivePackages) { foreach ($requiredPackages as $package) { $this->registerActivePackage($package); } $this->sortAndSavePackageStates(); } } protected function registerActivePackage(PackageInterface $package) { // reset the active packages so they are rebuilt. $this->activePackages = []; $this->packagePathMatchRegex = null; $this->packageStatesConfiguration['packages'][$package->getPackageKey()] = ['packagePath' => str_replace($this->packagesBasePath, '', $package->getPackagePath())]; } /** * Scans all directories in the packages directories for available packages. * For each package a Package object is created and stored in $this->packages. * @internal */ public function scanAvailablePackages() { if (Environment::isComposerMode()) { return; } $packagePaths = $this->scanPackagePathsForExtensions(); $packages = []; foreach ($packagePaths as $packageKey => $packagePath) { $packages[$packageKey] = ['packagePath' => str_replace($this->packagesBasePath, '', $packagePath)]; } $this->availablePackagesScanned = true; $registerOnlyNewPackages = !empty($this->packages); $this->registerPackagesFromConfiguration($packages, $registerOnlyNewPackages); } /** * Resolves a path in the form EXT:vendor/package/Path/To/Resource to an absolute filesystem path * * @throws UnknownPackageException * @throws UnknownPackagePathException */ public function resolvePackagePath(string $path): string { $packageKey = $this->extractPackageKeyFromPackagePath($path); $package = $this->getPackage($packageKey); return str_replace('EXT:' . $packageKey . '/', $package->getPackagePath(), $path); } /** * Extracts the package key from a path in the form EXT:vendor/package/Path/To/Resource * * @throws UnknownPackageException * @throws UnknownPackagePathException * @internal */ public function extractPackageKeyFromPackagePath(string $path): string { if (!PathUtility::isExtensionPath($path)) { throw new UnknownPackageException('Given path is not an extension path starting with "EXT:" ' . $path, 1631630764); } if (!isset($this->packagePathMatchRegex)) { $this->packagePathMatchRegex = sprintf( '/^EXT:(%s)\//', implode( '|', array_map( static function (string $packageKey): string { return preg_quote($packageKey, '/'); }, array_merge( array_keys($this->getActivePackages()), array_keys($this->packageAliasMap), array_keys($this->composerNameToPackageKeyMap) ) ) ) ); } $result = preg_match($this->packagePathMatchRegex, $path, $matches); if (!$result || empty($matches[1])) { throw new UnknownPackagePathException('Package path "' . $path . '" is not available. Please check if the package referenced in the path exists and that the package key is correct (package keys are case sensitive).', 1631630087); } return $matches[1]; } /** * Event listener to retrigger scanning of available packages. */ public function packagesMayHaveChanged(PackagesMayHaveChangedEvent $event): void { $this->scanAvailablePackages(); } /** * Fetches all directories from sysext/local locations and checks if the extension contains an composer.json * with a type "typo3-cms-framework", or "typo3-cms-extension". * * @return array */ protected function scanPackagePathsForExtensions() { $collectedExtensionPaths = []; $this->populateFrameworkAndComposerPackageNames(); foreach ($this->getPackageBasePaths() as $packageBasePath) { // Only add the extension if we have a composer.json and the extension is not yet registered. // This is crucial in order to allow overriding of system extension by local extensions // and strongly depends on the order of paths defined in $this->packagesBasePaths. $finder = new Finder(); $finder ->name('composer.json') ->followLinks() ->depth(0) ->ignoreUnreadableDirs() ->in($packageBasePath); /** @var SplFileInfo $fileInfo */ foreach ($finder as $fileInfo) { try { $composerManifest = json_decode($fileInfo->getContents(), false, 512, JSON_THROW_ON_ERROR); } catch (\JsonException) { continue; } $packageType = $composerManifest->type ?? ''; // Only allow typo3-cms package types if (!str_starts_with($packageType, 'typo3-cms-')) { continue; } $packageProvides = array_keys((array)($composerManifest->extra->{'typo3/cms'}->Package->providesPackages ?? [])); $this->installedPackageNames = array_merge($this->installedPackageNames, $packageProvides); $path = PathUtility::dirname($fileInfo->getPathname()); // Fix Windows backslashes $currentPath = GeneralUtility::fixWindowsFilePath($path) . '/'; $packageKey = $this->getPackageKeyFromManifest($composerManifest, $currentPath); if (!isset($collectedExtensionPaths[$packageKey])) { $collectedExtensionPaths[$packageKey] = $currentPath; } } } return $collectedExtensionPaths; } /** * Requires and registers all packages which were defined in packageStatesConfiguration * * @param array $packages * @param bool $registerOnlyNewPackages * @throws Exception\InvalidPackageStateException * @throws Exception\PackageStatesFileNotWritableException */ protected function registerPackagesFromConfiguration(array $packages, $registerOnlyNewPackages = false) { $packageStatesHasChanged = false; foreach ($packages as $packageKey => $stateConfiguration) { if ($registerOnlyNewPackages && $this->isPackageRegistered($packageKey)) { continue; } if (!isset($stateConfiguration['packagePath'])) { $this->unregisterPackageByPackageKey($packageKey); $packageStatesHasChanged = true; continue; } try { $packagePath = PathUtility::sanitizeTrailingSeparator($this->packagesBasePath . $stateConfiguration['packagePath']); $package = new Package($this, $packageKey, $packagePath); } catch (InvalidPackagePathException|InvalidPackageKeyException|InvalidPackageManifestException $exception) { $this->unregisterPackageByPackageKey($packageKey); $packageStatesHasChanged = true; continue; } $this->registerPackage($package); } if ($packageStatesHasChanged) { $this->sortAndSavePackageStates(); } } /** * Register a native TYPO3 package * * @param PackageInterface $package The Package to be registered * @return PackageInterface * @throws Exception\InvalidPackageStateException * @internal */ public function registerPackage(PackageInterface $package) { $packageKey = $package->getPackageKey(); if ($this->isPackageRegistered($packageKey)) { throw new InvalidPackageStateException('Package "' . $packageKey . '" is already registered.', 1338996122); } $composerName = $package->getValueFromComposerManifest('name'); if ($composerName !== $packageKey) { $this->composerNameToPackageKeyMap[$composerName] = $packageKey; } $this->packages[$packageKey] = $package; foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) { $this->packageAliasMap[$packageToReplace] = $package->getPackageKey(); } return $package; } /** * Unregisters a package from the list of available packages * * @param string|PackageKey $packageKey Package Key of the package to be unregistered */ protected function unregisterPackageByPackageKey($packageKey) { try { $package = $this->getPackage($packageKey); foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) { unset($this->packageAliasMap[$packageToReplace]); } } catch (UnknownPackageException $e) { } $this->composerNameToPackageKeyMap = array_filter( $this->composerNameToPackageKeyMap, static function (string $aliasedKey) use ($packageKey): bool { return $aliasedKey !== $packageKey; } ); unset($this->packages[$packageKey]); unset($this->packageStatesConfiguration['packages'][$packageKey]); } /** * Resolves a TYPO3 package key from a composer package name. * * @param string|PackageName $composerName * @return string|PackageKey|PackageName * @internal */ public function getPackageKeyFromComposerName($composerName) { if (isset($this->packageAliasMap[$composerName])) { return $this->packageAliasMap[$composerName]; } if (isset($this->composerNameToPackageKeyMap[$composerName])) { return $this->composerNameToPackageKeyMap[$composerName]; } return $composerName; } /** * Returns a PackageInterface object for the specified package. * A package is available, if the package directory contains valid MetaData information. * * @param string|PackageKey $packageKey * @return PackageInterface The requested package object * @throws Exception\UnknownPackageException if the specified package is not known */ public function getPackage($packageKey) { if (!$this->isPackageRegistered($packageKey) && !$this->isPackageAvailable($packageKey)) { 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); } return $this->packages[$this->getPackageKeyFromComposerName($packageKey)]; } /** * Returns TRUE if a package is available (the package's files exist in the packages directory) * or FALSE if it's not. If a package is available it doesn't mean necessarily that it's active! * * @param string|PackageKey $packageKey The key of the package to check * @return bool TRUE if the package is available, otherwise FALSE */ public function isPackageAvailable($packageKey) { if ($this->isPackageRegistered($packageKey)) { return true; } // If activePackages is empty, the PackageManager is currently initializing // thus packages should not be scanned if (!$this->availablePackagesScanned && !empty($this->activePackages)) { $this->scanAvailablePackages(); } return $this->isPackageRegistered($packageKey); } /** * Returns TRUE if a package is activated or FALSE if it's not. * * @param string|PackageKey $packageKey The key of the package to check * @return bool TRUE if package is active, otherwise FALSE */ public function isPackageActive($packageKey) { $packageKey = $this->getPackageKeyFromComposerName($packageKey); return isset($this->packageStatesConfiguration['packages'][$packageKey]); } /** * Deactivates a package and updates the packagestates configuration * * @param string|PackageKey $packageKey * @throws Exception\PackageStatesFileNotWritableException * @throws Exception\ProtectedPackageKeyException * @throws Exception\UnknownPackageException * @internal */ public function deactivatePackage($packageKey) { if (!$this->isPackageActive($packageKey)) { return; } $package = $this->getPackage($packageKey); if ($package->isProtected()) { throw new ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be deactivated.', 1308662891); } $packagesWithDependencies = $this->sortActivePackagesByDependencies(); foreach ($packagesWithDependencies as $packageStateKey => $packageStateConfiguration) { if ($packageKey === $packageStateKey || empty($packageStateConfiguration['dependencies'])) { continue; } if (in_array($packageKey, $packageStateConfiguration['dependencies'], true)) { $this->deactivatePackage($packageStateKey); } } $this->activePackages = []; $this->packagePathMatchRegex = null; unset($this->packageStatesConfiguration['packages'][$packageKey]); $this->sortAndSavePackageStates(); } /** * @param string|PackageKey $packageKey * @internal */ public function activatePackage($packageKey) { $package = $this->getPackage($packageKey); $this->registerTransientClassLoadingInformationForPackage($package); if ($this->isPackageActive($packageKey)) { return; } $this->registerActivePackage($package); $this->sortAndSavePackageStates(); } /** * @throws \TYPO3\CMS\Core\Exception */ protected function registerTransientClassLoadingInformationForPackage(PackageInterface $package) { if (Environment::isComposerMode()) { return; } ClassLoadingInformation::registerTransientClassLoadingInformationForPackage($package); } /** * Removes a package from the file system. * * @param string|PackageKey $packageKey * @throws Exception * @throws Exception\ProtectedPackageKeyException * @throws Exception\UnknownPackageException * @internal */ public function deletePackage($packageKey) { if (!$this->isPackageAvailable($packageKey)) { throw new UnknownPackageException('Package "' . $packageKey . '" is not available and cannot be removed.', 1166543253); } $package = $this->getPackage($packageKey); if ($package->isProtected()) { throw new ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be removed.', 1220722120); } if ($this->isPackageActive($packageKey)) { $this->deactivatePackage($packageKey); } $this->unregisterPackage($package); $this->sortAndSavePackageStates(); $packagePath = $package->getPackagePath(); $deletion = GeneralUtility::rmdir($packagePath, true); if ($deletion === false) { throw new Exception('Please check file permissions. The directory "' . $packagePath . '" for package "' . $packageKey . '" could not be removed.', 1301491089); } } /** * Returns an array of \TYPO3\CMS\Core\Package objects of all active packages. * A package is active, if it is available and has been activated in the package * manager settings. * * @return array */ public function getActivePackages(bool $includeAppPackage = false) { if (empty($this->activePackages) && !empty($this->packageStatesConfiguration['packages'])) { foreach ($this->packageStatesConfiguration['packages'] as $packageKey => $packageConfig) { $this->activePackages[$packageKey] = $this->getPackage($packageKey); } } $activePackages = $this->activePackages; if (!$includeAppPackage) { unset($activePackages[VirtualAppPackage::APP_PACKAGE_KEY]); } return $activePackages; } /** * Returns TRUE if a package was already registered or FALSE if it's not. * * @param string|PackageKey $packageKey * @return bool */ protected function isPackageRegistered($packageKey) { $packageKey = $this->getPackageKeyFromComposerName($packageKey); return isset($this->packages[$packageKey]); } /** * Orders all active packages by comparing their dependencies. By this, the packages * and package configurations arrays holds all packages in the correct * initialization order. * * @return array */ protected function sortActivePackagesByDependencies() { $packagesWithDependencies = $this->resolvePackageDependencies($this->packageStatesConfiguration['packages']); // sort the packages by key at first, so we get a stable sorting of "equivalent" packages afterwards ksort($packagesWithDependencies); $sortedPackageKeys = $this->sortPackageStatesConfigurationByDependency($packagesWithDependencies); // Reorder the packages according to the loading order $this->packageStatesConfiguration['packages'] = []; foreach ($sortedPackageKeys as $packageKey) { $this->registerActivePackage($this->packages[$packageKey]); } return $packagesWithDependencies; } /** * Resolves the dependent packages from the meta data of all packages recursively. The * resolved direct or indirect dependencies of each package will put into the package * states configuration array. * * @param array $packageConfig * @return array */ protected function resolvePackageDependencies($packageConfig) { $packagesWithDependencies = []; foreach ($packageConfig as $packageKey => $_) { $packagesWithDependencies[$packageKey]['dependencies'] = $this->getDependencyArrayForPackage($packageKey); $packagesWithDependencies[$packageKey]['suggestions'] = $this->getSuggestionArrayForPackage($packageKey); } return $packagesWithDependencies; } /** * Returns an array of suggested package keys for the given package. * * @param string|PackageKey $packageKey The package key to fetch the suggestions for * @return list|null An array of directly suggested packages */ protected function getSuggestionArrayForPackage($packageKey) { if (!isset($this->packages[$packageKey])) { return null; } $suggestedPackageKeys = []; $suggestedPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_SUGGESTS); foreach ($suggestedPackageConstraints as $constraint) { if ($constraint instanceof PackageConstraint) { $suggestedPackageName = $constraint->getValue(); $suggestedPackageKey = $this->getPackageKeyFromComposerName($suggestedPackageName); if (isset($this->packages[$suggestedPackageKey])) { $suggestedPackageKeys[] = $suggestedPackageKey; } } } return array_reverse($suggestedPackageKeys); } /** * Saves the current content of $this->packageStatesConfiguration to the * PackageStates.php file. * * @throws Exception\PackageStatesFileNotWritableException */ protected function savePackageStates() { $this->packageStatesConfiguration['version'] = 5; $fileDescription = "# PackageStates.php\n\n"; $fileDescription .= "# This file is maintained by TYPO3's package management. Although you can edit it\n"; $fileDescription .= "# manually, you should rather use the extension manager for maintaining packages.\n"; $fileDescription .= "# This file will be regenerated automatically if it doesn't exist. Deleting this file\n"; $fileDescription .= "# should, however, never become necessary if you use the package commands.\n"; if (!@is_writable($this->packageStatesPathAndFilename)) { // If file does not exist, try to create it $fileHandle = @fopen($this->packageStatesPathAndFilename, 'x'); if (!$fileHandle) { throw new PackageStatesFileNotWritableException( 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), 1382449759 ); } fclose($fileHandle); } $packageConfiguration = $this->packageStatesConfiguration; // The VirtualAppPackage must never be written to PackageStates.php // It is either pulled from cache, or registered manually after // all real packages have been pulled in from disk unset($packageConfiguration['packages'][VirtualAppPackage::APP_PACKAGE_KEY]); $packageStatesCode = "packageStatesPathAndFilename, $packageStatesCode, true); // Cache depends on package states file, therefore we invalidate it $this->packageCache->invalidate(); GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($this->packageStatesPathAndFilename); } /** * Saves the current content of $this->packageStatesConfiguration to the * PackageStates.php file. * * @throws Exception\PackageStatesFileNotWritableException */ protected function sortAndSavePackageStates() { $this->sortActivePackagesByDependencies(); $this->savePackageStates(); } /** * Check the conformance of the given package key * * @param string|PackageKey $packageKey The package key to validate * @return bool If the package key is valid, returns TRUE otherwise FALSE */ public function isPackageKeyValid($packageKey) { return preg_match(PackageInterface::PATTERN_MATCH_EXTENSIONKEY, $packageKey) === 1 || preg_match(PackageInterface::PATTERN_MATCH_COMPOSER_NAME, $packageKey) === 1 || preg_match(PackageInterface::PATTERN_MATCH_PACKAGEKEY, $packageKey) === 1; } /** * Returns an array of \TYPO3\CMS\Core\Package objects of all available packages. * A package is available, if the package directory contains valid meta information. * * @return array Array of PackageInterface */ public function getAvailablePackages() { if ($this->availablePackagesScanned === false) { $this->scanAvailablePackages(); } return $this->packages; } /** * Unregisters a package from the list of available packages * * @param PackageInterface $package The package to be unregistered * @throws Exception\InvalidPackageStateException * @internal */ public function unregisterPackage(PackageInterface $package) { $packageKey = $package->getPackageKey(); if (!$this->isPackageRegistered($packageKey)) { throw new InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1338996142); } $this->unregisterPackageByPackageKey($packageKey); } /** * Reloads a package and its information * * @param string|PackageKey $packageKey * @throws Exception\InvalidPackageStateException if the package isn't available * @internal */ public function reloadPackageInformation($packageKey) { if (!$this->isPackageRegistered($packageKey)) { throw new InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1436201329); } $package = $this->packages[$packageKey]; $packagePath = $package->getPackagePath(); $newPackage = new Package($this, $packageKey, $packagePath); $this->packages[$packageKey] = $newPackage; unset($package); } /** * Returns contents of Composer manifest as a stdObject * * @return \stdClass * @throws InvalidPackageManifestException * @internal */ public function getComposerManifest(string $manifestPath, bool $isBuildingPackageArtifact = false) { $composerManifest = new \stdClass(); if (file_exists($manifestPath . 'composer.json')) { $json = file_get_contents($manifestPath . 'composer.json'); if ($json !== false) { $composerManifest = json_decode($json); } if (!$composerManifest instanceof \stdClass) { throw new InvalidPackageManifestException('The composer.json found for extension "' . PathUtility::basename($manifestPath) . '" is invalid!', 1439555561); } } if ($isBuildingPackageArtifact // Framework packages (TYPO3 system extensions) derive their version from Typo3Version and are // not required to declare "providesPackages". Every other extension must declare the required // metadata in composer.json, since ext_emconf.php is no longer evaluated as a fallback for it. // @todo validate the contents and availability of the composer manifest instead of applying nullsafe fallbacks here || $this->isFrameworkPackage($composerManifest->name ?? '') || $this->declaresRequiredPackageMetadata($composerManifest) ) { return $composerManifest; } throw new InvalidPackageManifestException( sprintf( 'The composer.json of extension "%s" must declare the extension version and the "providesPackages" definition in the "extra/typo3/cms" section. See %s for details.', $this->getPackageKeyFromManifest($composerManifest, $manifestPath), Typo3Information::getDocsLink('changelog:deprecation-108345-1774126701'), ), 1780502553 ); } private function declaresRequiredPackageMetadata(\stdClass $manifest): bool { return isset($manifest->extra->{'typo3/cms'}->Package->providesPackages) && ( ($manifest->version ?? null) !== null || isset($manifest->extra->{'typo3/cms'}->version) ); } /** * Returns an array of dependent package keys for the given package. It will * do this recursively, so dependencies of dependent packages will also be * in the result. * * @param string|PackageKey $packageKey The package key to fetch the dependencies for * @param array $trace An array of already visited package keys, to detect circular dependencies * @return list|null An array of direct or indirect dependent packages * @throws Exception\InvalidPackageKeyException */ protected function getDependencyArrayForPackage($packageKey, array &$dependentPackageKeys = [], array $trace = []) { if (!isset($this->packages[$packageKey])) { return null; } if (in_array($packageKey, $trace, true) !== false) { return $dependentPackageKeys; } $trace[] = $packageKey; $dependentPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_DEPENDS); foreach ($dependentPackageConstraints as $constraint) { if ($constraint instanceof PackageConstraint) { $dependentPackageName = $constraint->getValue(); // This check is done here for classic mode, to ignore "php" package for // dependency ordering of extensions. In Composer mode, packages won't have // this dependency tracked anymore at this point. if ($dependentPackageName === 'php') { continue; } $dependentPackageKey = $this->getPackageKeyFromComposerName($dependentPackageName); if (in_array($dependentPackageKey, $dependentPackageKeys, true) === false && in_array($dependentPackageKey, $trace, true) === false) { $dependentPackageKeys[] = $dependentPackageKey; } $this->getDependencyArrayForPackage($dependentPackageKey, $dependentPackageKeys, $trace); } } return array_reverse($dependentPackageKeys); } /** * Resolves package key from Composer manifest * * If it is a TYPO3 package the name of the containing directory will be used. * * Else if the composer name of the package matches the first part of the lowercased namespace of the package, the mixed * case version of the composer name / namespace will be used, with backslashes replaced by dots. * * Else the composer name will be used with the slash replaced by a dot * * @throws Exception\InvalidPackageManifestException */ protected function getPackageKeyFromManifest(\stdClass $manifest, string $packagePath): string { if (!empty($manifest->extra->{'typo3/cms'}->{'extension-key'})) { return $manifest->extra->{'typo3/cms'}->{'extension-key'}; } if (empty($manifest->name) || (isset($manifest->type) && str_starts_with($manifest->type, 'typo3-cms-'))) { throw new InvalidPackageManifestException('Invalid composer manifest (no extension key set) in package path: ' . $packagePath, 1348146451); } return $manifest->name; } /** * The order of paths is crucial for allowing overriding of system extension by local extensions. * Pay attention if you change order of the paths here. * * @return array{local?: string, system?: string} */ protected function getPackageBasePaths() { if ($this->packagesBasePaths === []) { // Check if the directory even exists and if it is not empty if (is_dir(Environment::getExtensionsPath()) && $this->hasSubDirectories(Environment::getExtensionsPath())) { $this->packagesBasePaths['local'] = Environment::getExtensionsPath() . '/*/'; } $this->packagesBasePaths['system'] = Environment::getFrameworkBasePath() . '/*/'; } return $this->packagesBasePaths; } /** * Returns true if the given path has valid subdirectories, false otherwise. */ protected function hasSubDirectories(string $path): bool { return !empty(glob(rtrim($path, '/\\') . '/*', GLOB_ONLYDIR)); } /** * @param array $packageStatesConfiguration * @return list Returns the packageStatesConfiguration sorted by dependencies * @throws \UnexpectedValueException */ protected function sortPackageStatesConfigurationByDependency(array $packageStatesConfiguration) { return $this->dependencyOrderingService->calculateOrder($this->buildDependencyGraph($packageStatesConfiguration)); } /** * Convert the package configuration into a dependency definition * * This converts "dependencies" and "suggestions" to "after" syntax for the usage in DependencyOrderingService * * @param array $packageStatesConfiguration * @param array $packageKeys * @return array * @throws \UnexpectedValueException */ protected function convertConfigurationForGraph(array $packageStatesConfiguration, array $packageKeys) { $dependencies = []; foreach ($packageKeys as $packageKey) { if (!isset($packageStatesConfiguration[$packageKey]['dependencies']) && !isset($packageStatesConfiguration[$packageKey]['suggestions'])) { continue; } $dependencies[$packageKey] = [ 'after' => [], ]; if (isset($packageStatesConfiguration[$packageKey]['dependencies'])) { foreach ($packageStatesConfiguration[$packageKey]['dependencies'] as $dependentPackageKey) { if (!in_array($dependentPackageKey, $packageKeys, true)) { throw new \UnexpectedValueException( 'The package "' . $packageKey . '" depends on "' . $dependentPackageKey . '" which is not present in the system.', 1519931815 ); } $dependencies[$packageKey]['after'][] = $dependentPackageKey; } } if (isset($packageStatesConfiguration[$packageKey]['suggestions'])) { foreach ($packageStatesConfiguration[$packageKey]['suggestions'] as $suggestedPackageKey) { // skip suggestions on not existing packages if (in_array($suggestedPackageKey, $packageKeys, true)) { // Suggestions actually have never been meant to influence loading order. // We misuse this currently, as there is no other way to influence the loading order // for not-required packages (soft-dependency). // When considering suggestions for the loading order, we might create a cyclic dependency // if the suggested package already has a real dependency on this package, so the suggestion // has do be dropped in this case and must *not* be taken into account for loading order evaluation. $dependencies[$packageKey]['after-resilient'][] = $suggestedPackageKey; } } } } return $dependencies; } /** * Checks whether the given package name is a Composer dependency. * In classic mode some dedicated typo3 packages, PHP, composer-runtime-api * and PHP extensions are detected as Composer dependency. * * All other platform packages, if required, must be added to "providesPackages" * * @internal Only to be called within TYPO3\CMS\Core\Package namespace */ public function isComposerDependency(string $packageName): bool { if ($this->isFrameworkPackage($packageName)) { return false; } return // PHP version $packageName === 'php' // Composer version || $packageName === 'composer-runtime-api' // PHP extension || str_starts_with($packageName, 'ext-') // Installed packages (mostly shipped packages) || in_array($packageName, $this->installedPackageNames, true); } /** * @internal Only to be called within TYPO3\CMS\Core\Package namespace */ public function isFrameworkPackage(string $packageName): bool { if ($this->frameworkPackageNames === []) { $this->populateFrameworkAndComposerPackageNames(); } return in_array($packageName, $this->frameworkPackageNames, true); } /** * Adds all root packages of current dependency graph as dependency to all extensions * * This ensures that the framework extensions (aka sysext) are * always loaded first, before any other external extension. * * @param array $packageStateConfiguration * @param list $rootPackageKeys * @return array */ protected function addDependencyToFrameworkToAllExtensions(array $packageStateConfiguration, array $rootPackageKeys) { $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration); $extensionPackageKeys = array_diff(array_keys($packageStateConfiguration), $frameworkPackageKeys); foreach ($extensionPackageKeys as $packageKey) { // Remove framework packages from list $packageKeysWithoutFramework = array_diff( $packageStateConfiguration[$packageKey]['dependencies'], $frameworkPackageKeys ); // The order of the array_merge is crucial here, // we want the framework first $packageStateConfiguration[$packageKey]['dependencies'] = array_merge( $rootPackageKeys, $packageKeysWithoutFramework ); } return $packageStateConfiguration; } /** * Builds the dependency graph for all packages * * This method also introduces dependencies among the dependencies * to ensure the loading order is exactly as specified in the list. * * @param array $packageStateConfiguration * @return array> */ protected function buildDependencyGraph(array $packageStateConfiguration) { $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration); $frameworkPackagesDependencyGraph = $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $frameworkPackageKeys)); $packageStateConfiguration = $this->addDependencyToFrameworkToAllExtensions($packageStateConfiguration, $this->dependencyOrderingService->findRootIds($frameworkPackagesDependencyGraph)); $packageKeys = array_keys($packageStateConfiguration); return $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $packageKeys)); } /** * @param array $packageStateConfiguration * @return list */ protected function findFrameworkPackages(array $packageStateConfiguration) { $frameworkPackageKeys = []; foreach ($packageStateConfiguration as $packageKey => $packageConfiguration) { $package = $this->getPackage($packageKey); if ($package->getPackageMetaData()->isFrameworkType()) { $frameworkPackageKeys[] = $packageKey; } } return $frameworkPackageKeys; } /** * @internal */ public function warmupCaches(CacheWarmupEvent $event): void { if (Environment::isComposerMode()) { return; } if ($event->hasGroup('system')) { if (count($this->packageStatesConfiguration) === 0) { $this->loadPackageStates(); $this->initializePackageObjects(); } $this->validateResources(); $this->saveToPackageCache(); } } /** * Create PackageStates.php if missing and LocalConfiguration exists, used to have an Install Tool session running * * It is fired if PackageStates.php is deleted on a running instance, * all packages marked as "part of minimal system" are activated in this case. * @param bool $useFactoryDefault if true, use the "isPartOfFactoryDefault" otherwise use "isPartOfMinimalUsableSystem" * @internal */ public function recreatePackageStatesFileIfMissing(bool $useFactoryDefault = false): void { if (!Environment::isComposerMode() && !file_exists($this->packageStatesPathAndFilename)) { $packages = $this->getAvailablePackages(); foreach ($packages as $package) { if ($useFactoryDefault ? $package->isPartOfFactoryDefault() : $package->isPartOfMinimalUsableSystem()) { $this->activatePackage($package->getPackageKey()); } } $this->sortActivePackagesByDependencies(); $this->savePackageStates(); } } }