From a3e49c72137f1806b33e1656df59019320239685 Mon Sep 17 00:00:00 2001 From: Nicole Hummel Date: Mon, 23 Feb 2026 00:06:27 +0100 Subject: [PATCH 1/3] [BUGFIX] Remove obsolete basename removePrefixPath ComposerPackageManager::removePrefixPaths() strips a list of known path prefixes from a value before that value is looked up as a composer package name or as an extension key. The list contained the plain base name of the project root folder, for example `typo3` for a project checked out into a folder named `typo3`. That entry is a relative single segment prefix, so it also matches the vendor segment of a composer package name. With a project root folder named `typo3` the lookup value `typo3/testing-framework` is reduced to `testing-framework` and `typo3/sysext/core` is reduced to `sysext/core`. Both lookups fail, and functional tests abort with a method call on null, for example in Testbase::linkFrameworkExtensionsToInstance(). The entry had been added together with the relative path handling in getPackageFromPathFallback(), which resolves `..//...` values by prefixing the project root real path and canonicalizing the result. The base name entry is not needed for that, so remove it. Resolves: #665 Releases: main, 9, 8 --- Classes/Composer/ComposerPackageManager.php | 1 - .../Composer/ComposerPackageManagerTest.php | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Classes/Composer/ComposerPackageManager.php b/Classes/Composer/ComposerPackageManager.php index 212cf408..d036fe9d 100644 --- a/Classes/Composer/ComposerPackageManager.php +++ b/Classes/Composer/ComposerPackageManager.php @@ -655,7 +655,6 @@ private function removePrefixPaths(string $path): string rtrim($this->rootPackage()->getVendorDir(), '/') . '/', rtrim($this->rootPackage()->getWebDir(), '/') . '/', rtrim($this->getRootPath(), '/') . '/', - basename($this->getRootPath()) . '/', ]; foreach ($removePaths as $removePath) { if (str_starts_with($path, $removePath)) { diff --git a/Tests/Unit/Composer/ComposerPackageManagerTest.php b/Tests/Unit/Composer/ComposerPackageManagerTest.php index 8db5463e..fa48db89 100644 --- a/Tests/Unit/Composer/ComposerPackageManagerTest.php +++ b/Tests/Unit/Composer/ComposerPackageManagerTest.php @@ -471,6 +471,24 @@ public function prepareResolvePackageNameReturnsExpectedValues(string $name, str self::assertSame($expected, $resolved, sprintf('"%s" resolved to "%s"', $name, $expected)); } + /** + * The project root folder name must not be removed as path prefix. It is a + * relative single segment prefix, and it therefore also matches the vendor + * segment of a composer package name, for example `typo3/cms-core` for a + * project checked out into a folder named `typo3`. + */ + #[Test] + public function prepareResolvePackageNameKeepsProjectRootFolderNamePrefix(): void + { + $composerPackageManager = new ComposerPackageManager(); + $name = basename($composerPackageManager->getRootPath()) . '/some-package'; + + $prepareResolvePackageNameReflectionMethod = new \ReflectionMethod($composerPackageManager, 'prepareResolvePackageName'); + $resolved = $prepareResolvePackageNameReflectionMethod->invoke($composerPackageManager, $name); + + self::assertSame($name, $resolved, sprintf('"%s" resolved to "%s"', $name, $resolved)); + } + public static function resolvePackageNameReturnsExpectedPackageNameDataProvider(): \Generator { yield 'Composer package name returns unchanged (not checked for existence)' => [ From b574b1b57db5ccd3261b899a408c701f56669ac8 Mon Sep 17 00:00:00 2001 From: Nicole Hummel Date: Mon, 23 Feb 2026 00:27:31 +0100 Subject: [PATCH 2/3] [BUGFIX] Prevent resolving existing package names ComposerPackageManager::getPackageInfo() passed every handed over value through resolvePackageName(), which removes known path prefixes and classic mode notation prefixes before the lookup is done. A value that already is a known composer package name does not need that normalization at all, and the normalization can turn it into an unknown value when the vendor segment of the package name matches one of the removed prefixes. Look up the handed over value first and only fall back to the resolved name if it is not a known composer package. Resolves: #665 Releases: main, 9, 8 --- Classes/Composer/ComposerPackageManager.php | 2 +- .../Composer/ComposerPackageManagerTest.php | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Classes/Composer/ComposerPackageManager.php b/Classes/Composer/ComposerPackageManager.php index d036fe9d..65930fa1 100644 --- a/Classes/Composer/ComposerPackageManager.php +++ b/Classes/Composer/ComposerPackageManager.php @@ -101,7 +101,7 @@ public function getPackageInfoWithFallback(string $nameOrExtensionKeyOrPath): ?P */ public function getPackageInfo(string $name): ?PackageInfo { - return self::$packages[$this->resolvePackageName($name)] ?? null; + return self::$packages[$name] ?? self::$packages[$this->resolvePackageName($name)] ?? null; } /** diff --git a/Tests/Unit/Composer/ComposerPackageManagerTest.php b/Tests/Unit/Composer/ComposerPackageManagerTest.php index fa48db89..2b3b5b3b 100644 --- a/Tests/Unit/Composer/ComposerPackageManagerTest.php +++ b/Tests/Unit/Composer/ComposerPackageManagerTest.php @@ -197,6 +197,27 @@ public function coreExtensionCanBeResolvedWithRelativeLegacyPathPrefix(): void self::assertTrue($packageInfo->isSystemExtension()); } + /** + * A composer package name whose vendor segment matches one of the path + * prefixes removed for classic mode notation must not be rewritten before + * looking it up. The fixture extension used here is named + * `testing-framework/extension-absolute`, and `testing-framework` is the + * project root folder name of a default checkout. + */ + #[Test] + public function knownPackageNameIsNotModifiedBeforeLookup(): void + { + $subject = new ComposerPackageManager(); + // Register the fixture extension, it is not required by the root composer.json. + $subject->getPackageInfoWithFallback(__DIR__ . '/Fixtures/Extensions/ext_absolute'); + + $packageInfo = $subject->getPackageInfo('testing-framework/extension-absolute'); + + self::assertInstanceOf(PackageInfo::class, $packageInfo); + self::assertSame('testing-framework/extension-absolute', $packageInfo->getName()); + self::assertSame('absolute_real', $packageInfo->getExtensionKey()); + } + #[Test] public function extensionWithoutJsonCanBeResolvedByAbsolutePath(): void { From c223b050fcb68bede58de6f7f5fc7d48c23c5c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= Date: Thu, 23 Jul 2026 11:14:05 +0200 Subject: [PATCH 3/3] [BUGFIX] Resolve test extension paths consistently Testbase::linkTestExtensionsToInstance() resolves a configured test extension path with getPackageInfoWithFallback() and symlinks the extension into the test instance using the resolved extension key. Testbase::setUpPackageStates() resolves the very same value with getPackageInfo() instead, which only handles composer package names and extension keys. For a local extension that is not required by the root composer.json that lookup fails, and the code falls back to the directory basename. If the directory name differs from the extension key, the symlink and the PackageStates.php entry end up with different names, and instantiating the package fails with an InvalidPackagePathException. Use getPackageInfoWithFallback() in setUpPackageStates(), so both call sites derive the extension key the same way. Resolves: #683 Releases: main, 9, 8 --- Classes/Core/Testbase.php | 2 +- .../Extensions/ext_folder_name/composer.json | 20 +++++ Tests/Unit/Core/TestbaseTest.php | 88 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 Tests/Unit/Core/Fixtures/Extensions/ext_folder_name/composer.json create mode 100644 Tests/Unit/Core/TestbaseTest.php diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 60f7fd53..010b61a7 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -628,7 +628,7 @@ public function setUpPackageStates( // Activate test extensions that have been symlinked before foreach ($testExtensionPaths as $extensionPath) { - if ($packageInfo = $this->composerPackageManager->getPackageInfo($extensionPath)) { + if ($packageInfo = $this->composerPackageManager->getPackageInfoWithFallback($extensionPath)) { $extensionName = $packageInfo->getExtensionKey(); } else { $extensionName = basename($extensionPath); diff --git a/Tests/Unit/Core/Fixtures/Extensions/ext_folder_name/composer.json b/Tests/Unit/Core/Fixtures/Extensions/ext_folder_name/composer.json new file mode 100644 index 00000000..b372cab9 --- /dev/null +++ b/Tests/Unit/Core/Fixtures/Extensions/ext_folder_name/composer.json @@ -0,0 +1,20 @@ +{ + "name": "typo3/testing-framework-diverging-extension-key", + "description": "Test extension with a folder name differing from its extension key", + "type": "typo3-cms-extension", + "license": [ + "GPL-2.0-or-later" + ], + "require": { + "php": "*" + }, + "extra": { + "typo3/cms": { + "extension-key": "diverging_extension_key", + "version": "1.0.0", + "Package": { + "providesPackages": {} + } + } + } +} diff --git a/Tests/Unit/Core/TestbaseTest.php b/Tests/Unit/Core/TestbaseTest.php new file mode 100644 index 00000000..9d3419c8 --- /dev/null +++ b/Tests/Unit/Core/TestbaseTest.php @@ -0,0 +1,88 @@ +instancePath !== '' && is_dir($this->instancePath)) { + $this->removeDirectory($this->instancePath); + } + $this->instancePath = ''; + parent::tearDown(); + } + + /** + * Testbase::linkTestExtensionsToInstance() symlinks a test extension using the + * extension key determined by ComposerPackageManager::getPackageInfoWithFallback(). + * Testbase::setUpPackageStates() must determine the very same name, otherwise the + * written PackageStates.php points to a non-existing package path. + */ + #[Test] + public function setUpPackageStatesUsesExtensionKeyOfTestExtensionAndNotItsFolderName(): void + { + $extensionPath = __DIR__ . '/Fixtures/Extensions/ext_folder_name'; + // Folder name and extension key of the fixture extension differ on purpose. + self::assertSame('ext_folder_name', basename($extensionPath)); + + $this->instancePath = $this->createTestInstance(); + // Symlink the extension the way linkTestExtensionsToInstance() does it. + symlink($extensionPath, $this->instancePath . '/typo3conf/ext/diverging_extension_key'); + + $subject = new Testbase(); + $subject->setUpPackageStates($this->instancePath, [], [], [$extensionPath], []); + + $packageStates = require $this->instancePath . '/typo3conf/PackageStates.php'; + + self::assertArrayHasKey('diverging_extension_key', $packageStates['packages']); + self::assertArrayNotHasKey('ext_folder_name', $packageStates['packages']); + self::assertSame( + 'typo3conf/ext/diverging_extension_key/', + $packageStates['packages']['diverging_extension_key']['packagePath'] + ); + } + + private function createTestInstance(): string + { + $instancePath = sys_get_temp_dir() . '/testbase-' . bin2hex(random_bytes(8)); + mkdir($instancePath . '/typo3conf/ext', 0775, true); + return $instancePath; + } + + private function removeDirectory(string $path): void + { + foreach ((array)scandir($path) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $entryPath = $path . '/' . $entry; + if (is_link($entryPath) || is_file($entryPath)) { + unlink($entryPath); + continue; + } + $this->removeDirectory($entryPath); + } + rmdir($path); + } +}