Package: release.debian.org Severity: normal Tags: trixie X-Debbugs-Cc: [email protected], Debian Security Team <[email protected]> Control: affects -1 + src:composer User: [email protected] Usertags: pu
Hi, I’d like to fix five security issues that are not worth a DSA in the next point release (four are marked as no-dsa, the fifth one has no CVE assigned). [ Tests ] The test suite has been updated with the code change. [ Checklist ] [x] *all* changes are documented in the d/changelog [x] I reviewed all changes and I approve them [x] attach debdiff against the package in (old)stable [x] the issue is verified as fixed in unstable Thanks in advance for considering. Regards, taffit
diff -Nru composer-2.8.8/debian/changelog composer-2.8.8/debian/changelog --- composer-2.8.8/debian/changelog 2026-05-14 10:37:40.000000000 +0200 +++ composer-2.8.8/debian/changelog 2026-08-29 13:39:42.000000000 +0200 @@ -1,3 +1,20 @@ +composer (2.8.8-1+deb13u4) trixie; urgency=medium + + * Backport fixes from 2.10.2 and 2.10.3 releases: + + Validate resolved packages before locking (GHSA-499r-g7pc-vmp9) + [CVE-2026-59948] + + Validate package bin paths against path traversal (GHSA-gjfg-22fp-rrxx) + [CVE-2026-59946] + + Relax token validation on input, and hide more things on output (#12886) + + Sanitize URL-embedded usernames/tokens in verbose output + (GHSA-g6xq-892h-64w3) [CVE-2026-59947] + + Security: Validate package bin paths against path traversal + using symlinks (GHSA-96h3-5x6v-m776) [CVE-2026-59944] + + Security: Fixed command injection via malicious Perforce url + (GHSA-rvx4-ffvw-m9q3) + + -- David Prévot <[email protected]> Sat, 29 Aug 2026 13:39:42 +0200 + composer (2.8.8-1+deb13u3) trixie; urgency=medium * Fix regexp to support new GitHub installation tokens format diff -Nru composer-2.8.8/debian/patches/0022-Merge-commit-from-fork.patch composer-2.8.8/debian/patches/0022-Merge-commit-from-fork.patch --- composer-2.8.8/debian/patches/0022-Merge-commit-from-fork.patch 1970-01-01 01:00:00.000000000 +0100 +++ composer-2.8.8/debian/patches/0022-Merge-commit-from-fork.patch 2026-08-29 13:39:42.000000000 +0200 @@ -0,0 +1,1298 @@ +From: Jordi Boggiano <[email protected]> +Date: Wed, 1 Jul 2026 10:52:49 +0200 +Subject: Merge commit from fork + +* Validate resolved packages before locking (GHSA-499r-g7pc-vmp9) + +Add ValidatingArrayLoader::validatePackage() and call it from +LockTransaction::setResultPackages() so every package coming out of the +solver is re-checked before it is written to / installed from composer.lock. + +Packages served by a repository are loaded via the non-validating +ArrayLoader, so the name and source/dist url+reference checks done in +load() never run on them. This re-applies the security-sensitive subset of +those checks at the lock transaction (hit by both update and install), +guarding against malicious package names and "-"-prefixed urls/references +that could be interpreted as command-line options (argument injection). + +Test fixtures in SolverTest/InstallerTest that used shorthand package names +(A, B, ...) are renamed to valid vendor/name form so they pass validation. + +* Validate package bin paths against path traversal (GHSA-gjfg-22fp-rrxx) + +Reject bin entries containing ".." path segments in both +ValidatingArrayLoader::load() (so packagist.org and `composer validate` +flag them at publish time) and ValidatingArrayLoader::validatePackage() +(so every resolved package is re-checked before it is written to or +installed from the lock file, on both update and install). + +Without this a dependency's bin entry such as "../../../../etc/shadow" +makes BinaryInstaller resolve a path outside the package directory and +chmod(0777 & ~umask()) an arbitrary host file during composer install. + +Origin: upstream, https://github.com/composer/composer/commit/502c6c4f699802d9cf464728b3e8a95674f919a0 +Bug: https://github.com/composer/composer/security/advisories/GHSA-gjfg-22fp-rrxx + https://github.com/composer/composer/security/advisories/GHSA-499r-g7pc-vmp9 +Bug-Debian: https://security-tracker.debian.org/tracker/CVE-2026-59946 + https://security-tracker.debian.org/tracker/CVE-2026-59948 +--- + .../DependencyResolver/LockTransaction.php | 3 + + ...DownloadException.php => SecurityException.php} | 2 +- + .../Package/Loader/ValidatingArrayLoader.php | 66 +++ + .../Test/DependencyResolver/SolverTest.php | 484 ++++++++++----------- + tests/Composer/Test/InstallerTest.php | 16 +- + .../Package/Loader/ValidatingArrayLoaderTest.php | 89 ++++ + 6 files changed, 409 insertions(+), 251 deletions(-) + copy src/Composer/Exception/{IrrecoverableDownloadException.php => SecurityException.php} (85%) + +diff --git a/src/Composer/DependencyResolver/LockTransaction.php b/src/Composer/DependencyResolver/LockTransaction.php +index d77a211..8c94b3a 100644 +--- a/src/Composer/DependencyResolver/LockTransaction.php ++++ b/src/Composer/DependencyResolver/LockTransaction.php +@@ -14,6 +14,7 @@ namespace Composer\DependencyResolver; + + use Composer\Package\AliasPackage; + use Composer\Package\BasePackage; ++use Composer\Package\Loader\ValidatingArrayLoader; + use Composer\Package\Package; + use Composer\Pcre\Preg; + +@@ -70,6 +71,8 @@ class LockTransaction extends Transaction + if ($literal > 0) { + $package = $pool->literalToPackage($literal); + ++ ValidatingArrayLoader::validatePackage($package); ++ + $this->resultPackages['all'][] = $package; + if (!isset($this->unlockableMap[$package->id])) { + $this->resultPackages['non-dev'][] = $package; +diff --git a/src/Composer/Exception/IrrecoverableDownloadException.php b/src/Composer/Exception/SecurityException.php +similarity index 85% +copy from src/Composer/Exception/IrrecoverableDownloadException.php +copy to src/Composer/Exception/SecurityException.php +index a442786..29036a8 100644 +--- a/src/Composer/Exception/IrrecoverableDownloadException.php ++++ b/src/Composer/Exception/SecurityException.php +@@ -15,6 +15,6 @@ namespace Composer\Exception; + /** + * @author Jordi Boggiano <[email protected]> + */ +-class IrrecoverableDownloadException extends \RuntimeException ++class SecurityException extends \UnexpectedValueException + { + } +diff --git a/src/Composer/Package/Loader/ValidatingArrayLoader.php b/src/Composer/Package/Loader/ValidatingArrayLoader.php +index e3600d6..7830fd6 100644 +--- a/src/Composer/Package/Loader/ValidatingArrayLoader.php ++++ b/src/Composer/Package/Loader/ValidatingArrayLoader.php +@@ -12,7 +12,10 @@ + + namespace Composer\Package\Loader; + ++use Composer\Exception\SecurityException; + use Composer\Package\BasePackage; ++use Composer\Package\PackageInterface; ++use Composer\Package\RootPackageInterface; + use Composer\Pcre\Preg; + use Composer\Semver\Constraint\Constraint; + use Composer\Package\Version\VersionParser; +@@ -114,6 +117,21 @@ class ValidatingArrayLoader implements LoaderInterface + } else { + $this->validateFlatArray('bin'); + } ++ // A ".." path segment in a bin escapes the package install directory and lets the ++ // package chmod/point at an arbitrary host file during install (GHSA-gjfg-22fp-rrxx). ++ if (isset($this->config['bin']) && is_string($this->config['bin'])) { ++ if (Preg::isMatch('{(?:^|[\\\\/])\.\.(?:[\\\\/]|$)}', $this->config['bin'])) { ++ $this->errors[] = 'bin : invalid value ('.$this->config['bin'].'), must not contain a ".." path component'; ++ unset($this->config['bin']); ++ } ++ } elseif (isset($this->config['bin']) && is_array($this->config['bin'])) { ++ foreach ($this->config['bin'] as $key => $bin) { ++ if (is_string($bin) && Preg::isMatch('{(?:^|[\\\\/])\.\.(?:[\\\\/]|$)}', $bin)) { ++ $this->errors[] = 'bin.'.$key.' : invalid value ('.$bin.'), must not contain a ".." path component'; ++ unset($this->config['bin'][$key]); ++ } ++ } ++ } + } + + $this->validateArray('scripts'); // TODO validate event names & listener syntax +@@ -511,6 +529,54 @@ class ValidatingArrayLoader implements LoaderInterface + return null; + } + ++ /** ++ * Re-applies the security-sensitive subset of the load() validation to a resolved package ++ * which may have been loaded via the non-validating ArrayLoader, before it is written to or ++ * installed from the lock file. This guards against malicious package names and source/dist ++ * URLs or references that could be interpreted as command-line options (argument injection) ++ * by the VCS/download tooling. ++ * ++ * @throws SecurityException ++ */ ++ public static function validatePackage(PackageInterface $package): void ++ { ++ // The root package's name/metadata is locally controlled and already validated by ++ // RootPackageLoader (and its "__root__" placeholder name would be a false positive here). ++ // RootPackageInterface covers both RootPackage and RootAliasPackage. ++ if ($package instanceof RootPackageInterface) { ++ return; ++ } ++ ++ // getName() is already lowercased, so the uppercase style branch never fires and only ++ // structural/security failures throw. Platform packages return null here. ++ if (null !== ($err = self::hasPackageNamingError($package->getName()))) { ++ throw new SecurityException('Invalid package found during dependency resolution, aborting: '.$err); ++ } ++ ++ // A url or reference starting with a "-" may be misinterpreted as a command-line option ++ // by the VCS/download tooling, same protection as the source/dist checks done in load(). ++ $sourceDist = [ ++ 'source.url' => $package->getSourceUrl(), ++ 'source.reference' => $package->getSourceReference(), ++ 'dist.url' => $package->getDistUrl(), ++ 'dist.reference' => $package->getDistReference(), ++ ]; ++ foreach ($sourceDist as $field => $value) { ++ if ($value !== null && Preg::isMatch('{^\s*-}', $value)) { ++ throw new SecurityException($package->getName().' has an invalid '.$field.', it must not start with a "-": '.$value); ++ } ++ } ++ ++ // Bin paths are resolved relative to the package install dir and then chmod'd (and ++ // proxied) by BinaryInstaller. A ".." segment escapes that directory and lets a ++ // dependency chmod/point at an arbitrary host file (GHSA-gjfg-22fp-rrxx), so reject it. ++ foreach ($package->getBinaries() as $bin) { ++ if (Preg::isMatch('{(?:^|[\\\\/])\.\.(?:[\\\\/]|$)}', $bin)) { ++ throw new SecurityException($package->getName().' has an invalid bin '.$bin.', it must not contain ".." path segments'); ++ } ++ } ++ } ++ + /** + * @phpstan-param non-empty-string $property + * @phpstan-param non-empty-string $regex +diff --git a/tests/Composer/Test/DependencyResolver/SolverTest.php b/tests/Composer/Test/DependencyResolver/SolverTest.php +index 2e7d3eb..e75d30c 100644 +--- a/tests/Composer/Test/DependencyResolver/SolverTest.php ++++ b/tests/Composer/Test/DependencyResolver/SolverTest.php +@@ -61,10 +61,10 @@ class SolverTest extends TestCase + + public function testSolverInstallSingle(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageA], +@@ -73,7 +73,7 @@ class SolverTest extends TestCase + + public function testSolverRemoveIfNotRequested(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); + $this->reposComplete(); + + $this->checkSolverResult([ +@@ -83,10 +83,10 @@ class SolverTest extends TestCase + + public function testInstallNonExistingPackageFails(): void + { +- $this->repo->addPackage(self::getPackage('A', '1.0')); ++ $this->repo->addPackage(self::getPackage('a/a', '1.0')); + $this->reposComplete(); + +- $this->request->requireName('B', self::getVersionConstraint('==', '1')); ++ $this->request->requireName('b/b', self::getVersionConstraint('==', '1')); + + $this->createSolver(); + try { +@@ -96,7 +96,7 @@ class SolverTest extends TestCase + $problems = $e->getProblems(); + self::assertCount(1, $problems); + self::assertEquals(2, $e->getCode()); +- self::assertEquals("\n - Root composer.json requires b, it could not be found in any version, there may be a typo in the package name.", $problems[0]->getPrettyString($this->repoSet, $this->request, $this->pool, false)); ++ self::assertEquals("\n - Root composer.json requires b/b, it could not be found in any version, there may be a typo in the package name.", $problems[0]->getPrettyString($this->repoSet, $this->request, $this->pool, false)); + } + } + +@@ -105,13 +105,13 @@ class SolverTest extends TestCase + $repo1 = new ArrayRepository; + $repo2 = new ArrayRepository; + +- $repo1->addPackage($foo1 = self::getPackage('foo', '1')); +- $repo2->addPackage($foo2 = self::getPackage('foo', '1')); ++ $repo1->addPackage($foo1 = self::getPackage('foo/foo', '1')); ++ $repo2->addPackage($foo2 = self::getPackage('foo/foo', '1')); + + $this->repoSet->addRepository($repo1); + $this->repoSet->addRepository($repo2); + +- $this->request->requireName('foo'); ++ $this->request->requireName('foo/foo'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $foo1], +@@ -120,15 +120,15 @@ class SolverTest extends TestCase + + public function testSolverInstallWithDeps(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageB = self::getPackage('B', '1.1')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageB = self::getPackage('b/b', '1.1')); + +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('<', '1.1'), Link::TYPE_REQUIRE)]); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('<', '1.1'), Link::TYPE_REQUIRE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageB], +@@ -138,14 +138,14 @@ class SolverTest extends TestCase + + public function testSolverInstallHonoursNotEqualOperator(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageB11 = self::getPackage('B', '1.1')); +- $this->repo->addPackage($newPackageB12 = self::getPackage('B', '1.2')); +- $this->repo->addPackage($newPackageB13 = self::getPackage('B', '1.3')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageB11 = self::getPackage('b/b', '1.1')); ++ $this->repo->addPackage($newPackageB12 = self::getPackage('b/b', '1.2')); ++ $this->repo->addPackage($newPackageB13 = self::getPackage('b/b', '1.3')); + + $packageA->setRequires([ +- 'b' => new Link('A', 'B', new MultiConstraint([ ++ 'b/b' => new Link('a/a', 'b/b', new MultiConstraint([ + self::getVersionConstraint('<=', '1.3'), + self::getVersionConstraint('<>', '1.3'), + self::getVersionConstraint('!=', '1.2'), +@@ -154,7 +154,7 @@ class SolverTest extends TestCase + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $newPackageB11], +@@ -164,23 +164,23 @@ class SolverTest extends TestCase + + public function testSolverInstallWithDepsInOrder(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($packageC = self::getPackage('C', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($packageC = self::getPackage('c/c', '1.0')); + + $packageB->setRequires([ +- 'a' => new Link('B', 'A', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), +- 'c' => new Link('B', 'C', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('b/b', 'a/a', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'c/c' => new Link('b/b', 'c/c', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + $packageC->setRequires([ +- 'a' => new Link('C', 'A', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('c/c', 'a/a', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + + $this->reposComplete(); + +- $this->request->requireName('A'); +- $this->request->requireName('B'); +- $this->request->requireName('C'); ++ $this->request->requireName('a/a'); ++ $this->request->requireName('b/b'); ++ $this->request->requireName('c/c'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageA], +@@ -300,7 +300,7 @@ class SolverTest extends TestCase + + public function testSolverFixLocked(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); + $this->reposComplete(); + + $this->request->fixPackage($packageA); +@@ -310,8 +310,8 @@ class SolverTest extends TestCase + + public function testSolverFixLockedWithAlternative(): void + { +- $this->repo->addPackage(self::getPackage('A', '1.0')); +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); ++ $this->repo->addPackage(self::getPackage('a/a', '1.0')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); + $this->reposComplete(); + + $this->request->fixPackage($packageA); +@@ -321,15 +321,15 @@ class SolverTest extends TestCase + + public function testSolverUpdateDoesOnlyUpdate(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repoLocked->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageB = self::getPackage('B', '1.1')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repoLocked->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageB = self::getPackage('b/b', '1.1')); + $this->reposComplete(); + +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0.0.0'), Link::TYPE_REQUIRE)]); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0.0.0'), Link::TYPE_REQUIRE)]); + + $this->request->fixPackage($packageA); +- $this->request->requireName('B', self::getVersionConstraint('=', '1.1.0.0')); ++ $this->request->requireName('b/b', self::getVersionConstraint('=', '1.1.0.0')); + + $this->checkSolverResult([ + ['job' => 'update', 'from' => $packageB, 'to' => $newPackageB], +@@ -338,11 +338,11 @@ class SolverTest extends TestCase + + public function testSolverUpdateSingle(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($newPackageA = self::getPackage('A', '1.1')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($newPackageA = self::getPackage('a/a', '1.1')); + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'update', 'from' => $packageA, 'to' => $newPackageA], +@@ -351,17 +351,17 @@ class SolverTest extends TestCase + + public function testSolverUpdateAll(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repoLocked->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageA = self::getPackage('A', '1.1')); +- $this->repo->addPackage($newPackageB = self::getPackage('B', '1.1')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repoLocked->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageA = self::getPackage('a/a', '1.1')); ++ $this->repo->addPackage($newPackageB = self::getPackage('b/b', '1.1')); + +- $packageA->setRequires(['b' => new Link('A', 'B', new MatchAllConstraint(), Link::TYPE_REQUIRE)]); +- $newPackageA->setRequires(['b' => new Link('A', 'B', new MatchAllConstraint(), Link::TYPE_REQUIRE)]); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', new MatchAllConstraint(), Link::TYPE_REQUIRE)]); ++ $newPackageA->setRequires(['b/b' => new Link('a/a', 'b/b', new MatchAllConstraint(), Link::TYPE_REQUIRE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'update', 'from' => $packageB, 'to' => $newPackageB], +@@ -371,25 +371,25 @@ class SolverTest extends TestCase + + public function testSolverUpdateCurrent(): void + { +- $this->repoLocked->addPackage(self::getPackage('A', '1.0')); +- $this->repo->addPackage(self::getPackage('A', '1.0')); ++ $this->repoLocked->addPackage(self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage(self::getPackage('a/a', '1.0')); + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([]); + } + + public function testSolverUpdateOnlyUpdatesSelectedPackage(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repoLocked->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($packageAnewer = self::getPackage('A', '1.1')); +- $this->repo->addPackage($packageBnewer = self::getPackage('B', '1.1')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repoLocked->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($packageAnewer = self::getPackage('a/a', '1.1')); ++ $this->repo->addPackage($packageBnewer = self::getPackage('b/b', '1.1')); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + $this->request->fixPackage($packageB); + + $this->checkSolverResult([ +@@ -399,12 +399,12 @@ class SolverTest extends TestCase + + public function testSolverUpdateConstrained(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($newPackageA = self::getPackage('A', '1.2')); +- $this->repo->addPackage(self::getPackage('A', '2.0')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($newPackageA = self::getPackage('a/a', '1.2')); ++ $this->repo->addPackage(self::getPackage('a/a', '2.0')); + $this->reposComplete(); + +- $this->request->requireName('A', self::getVersionConstraint('<', '2.0.0.0')); ++ $this->request->requireName('a/a', self::getVersionConstraint('<', '2.0.0.0')); + + $this->checkSolverResult([[ + 'job' => 'update', +@@ -415,12 +415,12 @@ class SolverTest extends TestCase + + public function testSolverUpdateFullyConstrained(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($newPackageA = self::getPackage('A', '1.2')); +- $this->repo->addPackage(self::getPackage('A', '2.0')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($newPackageA = self::getPackage('a/a', '1.2')); ++ $this->repo->addPackage(self::getPackage('a/a', '2.0')); + $this->reposComplete(); + +- $this->request->requireName('A', self::getVersionConstraint('<', '2.0.0.0')); ++ $this->request->requireName('a/a', self::getVersionConstraint('<', '2.0.0.0')); + + $this->checkSolverResult([[ + 'job' => 'update', +@@ -431,13 +431,13 @@ class SolverTest extends TestCase + + public function testSolverUpdateFullyConstrainedPrunesInstalledPackages(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repoLocked->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageA = self::getPackage('A', '1.2')); +- $this->repo->addPackage(self::getPackage('A', '2.0')); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repoLocked->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageA = self::getPackage('a/a', '1.2')); ++ $this->repo->addPackage(self::getPackage('a/a', '2.0')); + $this->reposComplete(); + +- $this->request->requireName('A', self::getVersionConstraint('<', '2.0.0.0')); ++ $this->request->requireName('a/a', self::getVersionConstraint('<', '2.0.0.0')); + + $this->checkSolverResult([ + [ +@@ -454,20 +454,20 @@ class SolverTest extends TestCase + + public function testSolverAllJobs(): void + { +- $this->repoLocked->addPackage($packageD = self::getPackage('D', '1.0')); +- $this->repoLocked->addPackage($oldPackageC = self::getPackage('C', '1.0')); ++ $this->repoLocked->addPackage($packageD = self::getPackage('d/d', '1.0')); ++ $this->repoLocked->addPackage($oldPackageC = self::getPackage('c/c', '1.0')); + +- $this->repo->addPackage($packageA = self::getPackage('A', '2.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageB = self::getPackage('B', '1.1')); +- $this->repo->addPackage($packageC = self::getPackage('C', '1.1')); +- $this->repo->addPackage(self::getPackage('D', '1.0')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('<', '1.1'), Link::TYPE_REQUIRE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '2.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageB = self::getPackage('b/b', '1.1')); ++ $this->repo->addPackage($packageC = self::getPackage('c/c', '1.1')); ++ $this->repo->addPackage(self::getPackage('d/d', '1.0')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('<', '1.1'), Link::TYPE_REQUIRE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); +- $this->request->requireName('C'); ++ $this->request->requireName('a/a'); ++ $this->request->requireName('c/c'); + + $this->checkSolverResult([ + ['job' => 'remove', 'package' => $packageD], +@@ -479,16 +479,16 @@ class SolverTest extends TestCase + + public function testSolverThreeAlternativeRequireAndConflict(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '2.0')); +- $this->repo->addPackage($middlePackageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($newPackageB = self::getPackage('B', '1.1')); +- $this->repo->addPackage($oldPackageB = self::getPackage('B', '0.9')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('<', '1.1'), Link::TYPE_REQUIRE)]); +- $packageA->setConflicts(['b' => new Link('A', 'B', self::getVersionConstraint('<', '1.0'), Link::TYPE_CONFLICT)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '2.0')); ++ $this->repo->addPackage($middlePackageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($newPackageB = self::getPackage('b/b', '1.1')); ++ $this->repo->addPackage($oldPackageB = self::getPackage('b/b', '0.9')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('<', '1.1'), Link::TYPE_REQUIRE)]); ++ $packageA->setConflicts(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('<', '1.0'), Link::TYPE_CONFLICT)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $middlePackageB], +@@ -498,13 +498,13 @@ class SolverTest extends TestCase + + public function testSolverObsolete(): void + { +- $this->repoLocked->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $packageB->setReplaces(['a' => new Link('B', 'A', new MatchAllConstraint())]); ++ $this->repoLocked->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $packageB->setReplaces(['a/a' => new Link('b/b', 'a/a', new MatchAllConstraint())]); + + $this->reposComplete(); + +- $this->request->requireName('B'); ++ $this->request->requireName('b/b'); + + $this->checkSolverResult([ + ['job' => 'remove', 'package' => $packageA], +@@ -514,12 +514,12 @@ class SolverTest extends TestCase + + public function testInstallOneOfTwoAlternatives(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('A', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('a/a', '1.0')); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageA], +@@ -528,14 +528,14 @@ class SolverTest extends TestCase + + public function testInstallProvider(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageQ = self::getPackage('Q', '1.0')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageQ->setProvides(['b' => new Link('Q', 'B', self::getVersionConstraint('=', '1.0'), Link::TYPE_PROVIDE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageQ = self::getPackage('q/q', '1.0')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageQ->setProvides(['b/b' => new Link('q/q', 'b/b', self::getVersionConstraint('=', '1.0'), Link::TYPE_PROVIDE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + // must explicitly pick the provider, so error in this case + self::expectException('Composer\DependencyResolver\SolverProblemsException'); +@@ -545,15 +545,15 @@ class SolverTest extends TestCase + + public function testSkipReplacerOfExistingPackage(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageQ = self::getPackage('Q', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageQ->setReplaces(['b' => new Link('Q', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageQ = self::getPackage('q/q', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageQ->setReplaces(['b/b' => new Link('q/q', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageB], +@@ -563,14 +563,14 @@ class SolverTest extends TestCase + + public function testNoInstallReplacerOfMissingPackage(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageQ = self::getPackage('Q', '1.0')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageQ->setReplaces(['b' => new Link('Q', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageQ = self::getPackage('q/q', '1.0')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageQ->setReplaces(['b/b' => new Link('q/q', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + self::expectException('Composer\DependencyResolver\SolverProblemsException'); + $this->createSolver(); +@@ -579,16 +579,16 @@ class SolverTest extends TestCase + + public function testSkipReplacedPackageIfReplacerIsSelected(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageQ = self::getPackage('Q', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageQ->setReplaces(['b' => new Link('Q', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageQ = self::getPackage('q/q', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageQ->setReplaces(['b/b' => new Link('q/q', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); +- $this->request->requireName('Q'); ++ $this->request->requireName('a/a'); ++ $this->request->requireName('q/q'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageQ], +@@ -598,34 +598,34 @@ class SolverTest extends TestCase + + public function testPickOlderIfNewerConflicts(): void + { +- $this->repo->addPackage($packageX = self::getPackage('X', '1.0')); ++ $this->repo->addPackage($packageX = self::getPackage('x/x', '1.0')); + $packageX->setRequires([ +- 'a' => new Link('X', 'A', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REQUIRE), +- 'b' => new Link('X', 'B', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('x/x', 'a/a', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('x/x', 'b/b', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REQUIRE), + ]); + +- $this->repo->addPackage($packageA = self::getPackage('A', '2.0.0')); +- $this->repo->addPackage($newPackageA = self::getPackage('A', '2.1.0')); +- $this->repo->addPackage($newPackageB = self::getPackage('B', '2.1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '2.0.0')); ++ $this->repo->addPackage($newPackageA = self::getPackage('a/a', '2.1.0')); ++ $this->repo->addPackage($newPackageB = self::getPackage('b/b', '2.1.0')); + +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REQUIRE)]); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REQUIRE)]); + + // new package A depends on version of package B that does not exist + // => new package A is not installable +- $newPackageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '2.2.0.0'), Link::TYPE_REQUIRE)]); ++ $newPackageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '2.2.0.0'), Link::TYPE_REQUIRE)]); + + // add a package S replacing both A and B, so that S and B or S and A cannot be simultaneously installed + // but an alternative option for A and B both exists + // this creates a more difficult so solve conflict +- $this->repo->addPackage($packageS = self::getPackage('S', '2.0.0')); ++ $this->repo->addPackage($packageS = self::getPackage('s/s', '2.0.0')); + $packageS->setReplaces([ +- 'a' => new Link('S', 'A', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REPLACE), +- 'b' => new Link('S', 'B', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REPLACE), ++ 'a/a' => new Link('s/s', 'a/a', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REPLACE), ++ 'b/b' => new Link('s/s', 'b/b', self::getVersionConstraint('>=', '2.0.0.0'), Link::TYPE_REPLACE), + ]); + + $this->reposComplete(); + +- $this->request->requireName('X'); ++ $this->request->requireName('x/x'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $newPackageB], +@@ -636,15 +636,15 @@ class SolverTest extends TestCase + + public function testInstallCircularRequire(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB1 = self::getPackage('B', '0.9')); +- $this->repo->addPackage($packageB2 = self::getPackage('B', '1.1')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageB2->setRequires(['a' => new Link('B', 'A', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB1 = self::getPackage('b/b', '0.9')); ++ $this->repo->addPackage($packageB2 = self::getPackage('b/b', '1.1')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageB2->setRequires(['a/a' => new Link('b/b', 'a/a', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageB2], +@@ -654,22 +654,22 @@ class SolverTest extends TestCase + + public function testInstallAlternativeWithCircularRequire(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($packageC = self::getPackage('C', '1.0')); +- $this->repo->addPackage($packageD = self::getPackage('D', '1.0')); +- $packageA->setRequires(['b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageB->setRequires(['virtual' => new Link('B', 'Virtual', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); +- $packageC->setProvides(['virtual' => new Link('C', 'Virtual', self::getVersionConstraint('==', '1.0'), Link::TYPE_PROVIDE)]); +- $packageD->setProvides(['virtual' => new Link('D', 'Virtual', self::getVersionConstraint('==', '1.0'), Link::TYPE_PROVIDE)]); +- +- $packageC->setRequires(['a' => new Link('C', 'A', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE)]); +- $packageD->setRequires(['a' => new Link('D', 'A', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE)]); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($packageC = self::getPackage('c/c', '1.0')); ++ $this->repo->addPackage($packageD = self::getPackage('d/d', '1.0')); ++ $packageA->setRequires(['b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageB->setRequires(['virtual/virtual' => new Link('b/b', 'virtual/virtual', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageC->setProvides(['virtual/virtual' => new Link('c/c', 'virtual/virtual', self::getVersionConstraint('==', '1.0'), Link::TYPE_PROVIDE)]); ++ $packageD->setProvides(['virtual/virtual' => new Link('d/d', 'virtual/virtual', self::getVersionConstraint('==', '1.0'), Link::TYPE_PROVIDE)]); ++ ++ $packageC->setRequires(['a/a' => new Link('c/c', 'a/a', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE)]); ++ $packageD->setRequires(['a/a' => new Link('d/d', 'a/a', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE)]); + + $this->reposComplete(); + +- $this->request->requireName('A'); +- $this->request->requireName('C'); ++ $this->request->requireName('a/a'); ++ $this->request->requireName('c/c'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageB], +@@ -684,30 +684,30 @@ class SolverTest extends TestCase + */ + public function testUseReplacerIfNecessary(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($packageD = self::getPackage('D', '1.0')); +- $this->repo->addPackage($packageD2 = self::getPackage('D', '1.1')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($packageD = self::getPackage('d/d', '1.0')); ++ $this->repo->addPackage($packageD2 = self::getPackage('d/d', '1.1')); + + $packageA->setRequires([ +- 'b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), +- 'c' => new Link('A', 'C', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'c/c' => new Link('a/a', 'c/c', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + + $packageD->setReplaces([ +- 'b' => new Link('D', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), +- 'c' => new Link('D', 'C', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), ++ 'b/b' => new Link('d/d', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), ++ 'c/c' => new Link('d/d', 'c/c', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), + ]); + + $packageD2->setReplaces([ +- 'b' => new Link('D', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), +- 'c' => new Link('D', 'C', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), ++ 'b/b' => new Link('d/d', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), ++ 'c/c' => new Link('d/d', 'c/c', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REPLACE), + ]); + + $this->reposComplete(); + +- $this->request->requireName('A'); +- $this->request->requireName('D'); ++ $this->request->requireName('a/a'); ++ $this->request->requireName('d/d'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageD2], +@@ -717,32 +717,32 @@ class SolverTest extends TestCase + + public function testIssue265(): void + { +- $this->repo->addPackage($packageA1 = self::getPackage('A', '2.0.999999-dev')); +- $this->repo->addPackage($packageA2 = self::getPackage('A', '2.1-dev')); +- $this->repo->addPackage($packageA3 = self::getPackage('A', '2.2-dev')); +- $this->repo->addPackage($packageB1 = self::getPackage('B', '2.0.10')); +- $this->repo->addPackage($packageB2 = self::getPackage('B', '2.0.9')); +- $this->repo->addPackage($packageC = self::getPackage('C', '2.0-dev')); +- $this->repo->addPackage($packageD = self::getPackage('D', '2.0.9')); ++ $this->repo->addPackage($packageA1 = self::getPackage('a/a', '2.0.999999-dev')); ++ $this->repo->addPackage($packageA2 = self::getPackage('a/a', '2.1-dev')); ++ $this->repo->addPackage($packageA3 = self::getPackage('a/a', '2.2-dev')); ++ $this->repo->addPackage($packageB1 = self::getPackage('b/b', '2.0.10')); ++ $this->repo->addPackage($packageB2 = self::getPackage('b/b', '2.0.9')); ++ $this->repo->addPackage($packageC = self::getPackage('c/c', '2.0-dev')); ++ $this->repo->addPackage($packageD = self::getPackage('d/d', '2.0.9')); + + $packageC->setRequires([ +- 'a' => new Link('C', 'A', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), +- 'd' => new Link('C', 'D', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('c/c', 'a/a', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), ++ 'd/d' => new Link('c/c', 'd/d', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), + ]); + + $packageD->setRequires([ +- 'a' => new Link('D', 'A', self::getVersionConstraint('>=', '2.1'), Link::TYPE_REQUIRE), +- 'b' => new Link('D', 'B', self::getVersionConstraint('>=', '2.0-dev'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('d/d', 'a/a', self::getVersionConstraint('>=', '2.1'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('d/d', 'b/b', self::getVersionConstraint('>=', '2.0-dev'), Link::TYPE_REQUIRE), + ]); + +- $packageB1->setRequires(['a' => new Link('B', 'A', self::getVersionConstraint('==', '2.1.0.0-dev'), Link::TYPE_REQUIRE)]); +- $packageB2->setRequires(['a' => new Link('B', 'A', self::getVersionConstraint('==', '2.1.0.0-dev'), Link::TYPE_REQUIRE)]); ++ $packageB1->setRequires(['a/a' => new Link('b/b', 'a/a', self::getVersionConstraint('==', '2.1.0.0-dev'), Link::TYPE_REQUIRE)]); ++ $packageB2->setRequires(['a/a' => new Link('b/b', 'a/a', self::getVersionConstraint('==', '2.1.0.0-dev'), Link::TYPE_REQUIRE)]); + +- $packageB2->setReplaces(['d' => new Link('B', 'D', self::getVersionConstraint('==', '2.0.9.0'), Link::TYPE_REPLACE)]); ++ $packageB2->setReplaces(['d/d' => new Link('b/b', 'd/d', self::getVersionConstraint('==', '2.0.9.0'), Link::TYPE_REPLACE)]); + + $this->reposComplete(); + +- $this->request->requireName('C', self::getVersionConstraint('==', '2.0.0.0-dev')); ++ $this->request->requireName('c/c', self::getVersionConstraint('==', '2.0.0.0-dev')); + + self::expectException('Composer\DependencyResolver\SolverProblemsException'); + +@@ -752,10 +752,10 @@ class SolverTest extends TestCase + + public function testConflictResultEmpty(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); + $packageA->setConflicts([ +- 'b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_CONFLICT), ++ 'b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_CONFLICT), + ]); + + $this->reposComplete(); +@@ -763,8 +763,8 @@ class SolverTest extends TestCase + $emptyConstraint = new MatchAllConstraint(); + $emptyConstraint->setPrettyString('*'); + +- $this->request->requireName('A', $emptyConstraint); +- $this->request->requireName('B', $emptyConstraint); ++ $this->request->requireName('a/a', $emptyConstraint); ++ $this->request->requireName('b/b', $emptyConstraint); + + $this->createSolver(); + try { +@@ -776,25 +776,25 @@ class SolverTest extends TestCase + + $msg = "\n"; + $msg .= " Problem 1\n"; +- $msg .= " - Root composer.json requires a * -> satisfiable by A[1.0].\n"; +- $msg .= " - Root composer.json requires b * -> satisfiable by B[1.0].\n"; +- $msg .= " - A 1.0 conflicts with B 1.0.\n"; ++ $msg .= " - Root composer.json requires a/a * -> satisfiable by a/a[1.0].\n"; ++ $msg .= " - Root composer.json requires b/b * -> satisfiable by b/b[1.0].\n"; ++ $msg .= " - a/a 1.0 conflicts with b/b 1.0.\n"; + self::assertEquals($msg, $e->getPrettyString($this->repoSet, $this->request, $this->pool, false)); + } + } + + public function testUnsatisfiableRequires(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); + + $packageA->setRequires([ +- 'b' => new Link('A', 'B', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), + ]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->createSolver(); + try { +@@ -807,31 +807,31 @@ class SolverTest extends TestCase + + $msg = "\n"; + $msg .= " Problem 1\n"; +- $msg .= " - Root composer.json requires a * -> satisfiable by A[1.0].\n"; +- $msg .= " - A 1.0 requires b >= 2.0 -> found B[1.0] but it does not match the constraint.\n"; ++ $msg .= " - Root composer.json requires a/a * -> satisfiable by a/a[1.0].\n"; ++ $msg .= " - a/a 1.0 requires b/b >= 2.0 -> found b/b[1.0] but it does not match the constraint.\n"; + self::assertEquals($msg, $e->getPrettyString($this->repoSet, $this->request, $this->pool, false)); + } + } + + public function testRequireMismatchException(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($packageB2 = self::getPackage('B', '0.9')); +- $this->repo->addPackage($packageC = self::getPackage('C', '1.0')); +- $this->repo->addPackage($packageD = self::getPackage('D', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($packageB2 = self::getPackage('b/b', '0.9')); ++ $this->repo->addPackage($packageC = self::getPackage('c/c', '1.0')); ++ $this->repo->addPackage($packageD = self::getPackage('d/d', '1.0')); + + $packageA->setRequires([ +- 'b' => new Link('A', 'B', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + $packageB->setRequires([ +- 'c' => new Link('B', 'C', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'c/c' => new Link('b/b', 'c/c', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + $packageC->setRequires([ +- 'd' => new Link('C', 'D', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'd/d' => new Link('c/c', 'd/d', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + $packageD->setRequires([ +- 'b' => new Link('D', 'B', self::getVersionConstraint('<', '1.0'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('d/d', 'b/b', self::getVersionConstraint('<', '1.0'), Link::TYPE_REQUIRE), + ]); + + $this->reposComplete(); +@@ -839,7 +839,7 @@ class SolverTest extends TestCase + $emptyConstraint = new MatchAllConstraint(); + $emptyConstraint->setPrettyString('*'); + +- $this->request->requireName('A', $emptyConstraint); ++ $this->request->requireName('a/a', $emptyConstraint); + + $this->createSolver(); + try { +@@ -851,12 +851,12 @@ class SolverTest extends TestCase + + $msg = "\n"; + $msg .= " Problem 1\n"; +- $msg .= " - Root composer.json requires a * -> satisfiable by A[1.0].\n"; +- $msg .= " - A 1.0 requires b >= 1.0 -> satisfiable by B[1.0].\n"; +- $msg .= " - B 1.0 requires c >= 1.0 -> satisfiable by C[1.0].\n"; +- $msg .= " - C 1.0 requires d >= 1.0 -> satisfiable by D[1.0].\n"; +- $msg .= " - D 1.0 requires b < 1.0 -> satisfiable by B[0.9].\n"; +- $msg .= " - You can only install one version of a package, so only one of these can be installed: B[0.9, 1.0].\n"; ++ $msg .= " - Root composer.json requires a/a * -> satisfiable by a/a[1.0].\n"; ++ $msg .= " - a/a 1.0 requires b/b >= 1.0 -> satisfiable by b/b[1.0].\n"; ++ $msg .= " - b/b 1.0 requires c/c >= 1.0 -> satisfiable by c/c[1.0].\n"; ++ $msg .= " - c/c 1.0 requires d/d >= 1.0 -> satisfiable by d/d[1.0].\n"; ++ $msg .= " - d/d 1.0 requires b/b < 1.0 -> satisfiable by b/b[0.9].\n"; ++ $msg .= " - You can only install one version of a package, so only one of these can be installed: b/b[0.9, 1.0].\n"; + self::assertEquals($msg, $e->getPrettyString($this->repoSet, $this->request, $this->pool, false)); + } + } +@@ -890,22 +890,22 @@ class SolverTest extends TestCase + + public function testInstallRecursiveAliasDependencies(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '2.0')); +- $this->repo->addPackage($packageA2 = self::getPackage('A', '2.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '2.0')); ++ $this->repo->addPackage($packageA2 = self::getPackage('a/a', '2.0')); + + $packageA2->setRequires([ +- 'b' => new Link('A', 'B', self::getVersionConstraint('==', '2.0'), Link::TYPE_REQUIRE, '== 2.0'), ++ 'b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('==', '2.0'), Link::TYPE_REQUIRE, '== 2.0'), + ]); + $packageB->setRequires([ +- 'a' => new Link('B', 'A', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('b/b', 'a/a', self::getVersionConstraint('>=', '2.0'), Link::TYPE_REQUIRE), + ]); + + $this->repo->addPackage($packageA2Alias = self::getAliasPackage($packageA2, '1.1')); + + $this->reposComplete(); + +- $this->request->requireName('A', self::getVersionConstraint('==', '1.1.0.0')); ++ $this->request->requireName('a/a', self::getVersionConstraint('==', '1.1.0.0')); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageB], +@@ -916,19 +916,19 @@ class SolverTest extends TestCase + + public function testInstallDevAlias(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '2.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '2.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); + + $packageB->setRequires([ +- 'a' => new Link('B', 'A', self::getVersionConstraint('<', '2.0'), Link::TYPE_REQUIRE), ++ 'a/a' => new Link('b/b', 'a/a', self::getVersionConstraint('<', '2.0'), Link::TYPE_REQUIRE), + ]); + + $this->repo->addPackage($packageAAlias = self::getAliasPackage($packageA, '1.1')); + + $this->reposComplete(); + +- $this->request->requireName('A', self::getVersionConstraint('==', '2.0')); +- $this->request->requireName('B'); ++ $this->request->requireName('a/a', self::getVersionConstraint('==', '2.0')); ++ $this->request->requireName('b/b'); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageA], +@@ -940,22 +940,22 @@ class SolverTest extends TestCase + public function testInstallRootAliasesIfAliasOfIsInstalled(): void + { + // root aliased, required +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); + $this->repo->addPackage($packageAAlias = self::getAliasPackage($packageA, '1.1')); + $packageAAlias->setRootPackageAlias(true); + // root aliased, not required, should still be installed as it is root alias +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); + $this->repo->addPackage($packageBAlias = self::getAliasPackage($packageB, '1.1')); + $packageBAlias->setRootPackageAlias(true); + // regular alias, not required, alias should not be installed +- $this->repo->addPackage($packageC = self::getPackage('C', '1.0')); ++ $this->repo->addPackage($packageC = self::getPackage('c/c', '1.0')); + $this->repo->addPackage($packageCAlias = self::getAliasPackage($packageC, '1.1')); + + $this->reposComplete(); + +- $this->request->requireName('A', self::getVersionConstraint('==', '1.1')); +- $this->request->requireName('B', self::getVersionConstraint('==', '1.0')); +- $this->request->requireName('C', self::getVersionConstraint('==', '1.0')); ++ $this->request->requireName('a/a', self::getVersionConstraint('==', '1.1')); ++ $this->request->requireName('b/b', self::getVersionConstraint('==', '1.0')); ++ $this->request->requireName('c/c', self::getVersionConstraint('==', '1.0')); + + $this->checkSolverResult([ + ['job' => 'install', 'package' => $packageA], +@@ -978,47 +978,47 @@ class SolverTest extends TestCase + */ + public function testLearnPositiveLiteral(): void + { +- $this->repo->addPackage($packageA = self::getPackage('A', '1.0')); +- $this->repo->addPackage($packageB = self::getPackage('B', '1.0')); +- $this->repo->addPackage($packageC1 = self::getPackage('C', '1.0')); +- $this->repo->addPackage($packageC2 = self::getPackage('C', '2.0')); +- $this->repo->addPackage($packageD = self::getPackage('D', '1.0')); +- $this->repo->addPackage($packageE = self::getPackage('E', '1.0')); +- $this->repo->addPackage($packageF1 = self::getPackage('F', '1.0')); +- $this->repo->addPackage($packageF2 = self::getPackage('F', '2.0')); +- $this->repo->addPackage($packageG1 = self::getPackage('G', '1.0')); +- $this->repo->addPackage($packageG2 = self::getPackage('G', '2.0')); +- $this->repo->addPackage($packageG3 = self::getPackage('G', '3.0')); ++ $this->repo->addPackage($packageA = self::getPackage('a/a', '1.0')); ++ $this->repo->addPackage($packageB = self::getPackage('b/b', '1.0')); ++ $this->repo->addPackage($packageC1 = self::getPackage('c/c', '1.0')); ++ $this->repo->addPackage($packageC2 = self::getPackage('c/c', '2.0')); ++ $this->repo->addPackage($packageD = self::getPackage('d/d', '1.0')); ++ $this->repo->addPackage($packageE = self::getPackage('e/e', '1.0')); ++ $this->repo->addPackage($packageF1 = self::getPackage('f/f', '1.0')); ++ $this->repo->addPackage($packageF2 = self::getPackage('f/f', '2.0')); ++ $this->repo->addPackage($packageG1 = self::getPackage('g/g', '1.0')); ++ $this->repo->addPackage($packageG2 = self::getPackage('g/g', '2.0')); ++ $this->repo->addPackage($packageG3 = self::getPackage('g/g', '3.0')); + + $packageA->setRequires([ +- 'b' => new Link('A', 'B', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), +- 'c' => new Link('A', 'C', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), +- 'd' => new Link('A', 'D', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), ++ 'b/b' => new Link('a/a', 'b/b', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), ++ 'c/c' => new Link('a/a', 'c/c', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'd/d' => new Link('a/a', 'd/d', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), + ]); + + $packageB->setRequires([ +- 'e' => new Link('B', 'E', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), ++ 'e/e' => new Link('b/b', 'e/e', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), + ]); + + $packageC1->setRequires([ +- 'f' => new Link('C', 'F', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), ++ 'f/f' => new Link('c/c', 'f/f', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), + ]); + $packageC2->setRequires([ +- 'f' => new Link('C', 'F', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), +- 'g' => new Link('C', 'G', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'f/f' => new Link('c/c', 'f/f', self::getVersionConstraint('==', '1.0'), Link::TYPE_REQUIRE), ++ 'g/g' => new Link('c/c', 'g/g', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + + $packageD->setRequires([ +- 'f' => new Link('D', 'F', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), ++ 'f/f' => new Link('d/d', 'f/f', self::getVersionConstraint('>=', '1.0'), Link::TYPE_REQUIRE), + ]); + + $packageE->setRequires([ +- 'g' => new Link('E', 'G', self::getVersionConstraint('<=', '2.0'), Link::TYPE_REQUIRE), ++ 'g/g' => new Link('e/e', 'g/g', self::getVersionConstraint('<=', '2.0'), Link::TYPE_REQUIRE), + ]); + + $this->reposComplete(); + +- $this->request->requireName('A'); ++ $this->request->requireName('a/a'); + + $this->createSolver(); + +diff --git a/tests/Composer/Test/InstallerTest.php b/tests/Composer/Test/InstallerTest.php +index ae1383a..ae73ac4 100644 +--- a/tests/Composer/Test/InstallerTest.php ++++ b/tests/Composer/Test/InstallerTest.php +@@ -178,13 +178,13 @@ class InstallerTest extends TestCase + // when A requires B and B requires A, and A is a non-published root package + // the install of B should succeed + +- $a = self::getPackage('A', '1.0.0', 'Composer\Package\RootPackage'); ++ $a = self::getPackage('a/a', '1.0.0', 'Composer\Package\RootPackage'); + $a->setRequires([ +- 'b' => new Link('A', 'B', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), ++ 'b/b' => new Link('a/a', 'b/b', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), + ]); +- $b = self::getPackage('B', '1.0.0'); ++ $b = self::getPackage('b/b', '1.0.0'); + $b->setRequires([ +- 'a' => new Link('B', 'A', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), ++ 'a/a' => new Link('b/b', 'a/a', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), + ]); + + $cases[] = [ +@@ -198,13 +198,13 @@ class InstallerTest extends TestCase + // #480: when A requires B and B requires A, and A is a published root package + // only B should be installed, as A is the root + +- $a = self::getPackage('A', '1.0.0', 'Composer\Package\RootPackage'); ++ $a = self::getPackage('a/a', '1.0.0', 'Composer\Package\RootPackage'); + $a->setRequires([ +- 'b' => new Link('A', 'B', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), ++ 'b/b' => new Link('a/a', 'b/b', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), + ]); +- $b = self::getPackage('B', '1.0.0'); ++ $b = self::getPackage('b/b', '1.0.0'); + $b->setRequires([ +- 'a' => new Link('B', 'A', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), ++ 'a/a' => new Link('b/b', 'a/a', $v = self::getVersionConstraint('=', '1.0.0'), Link::TYPE_REQUIRE, $v->getPrettyString()), + ]); + + $cases[] = [ +diff --git a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +index 2477fa1..d1a9215 100644 +--- a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php ++++ b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +@@ -12,8 +12,10 @@ + + namespace Composer\Test\Package\Loader; + ++use Composer\Exception\SecurityException; + use Composer\Package\Loader\ValidatingArrayLoader; + use Composer\Package\Loader\InvalidPackageException; ++use Composer\Package\PackageInterface; + use Composer\Test\TestCase; + use PHPUnit\Framework\Attributes\DataProvider; + +@@ -397,6 +399,25 @@ class ValidatingArrayLoaderTest extends TestCase + 'require.foo/Bar : a package cannot set a require on itself', + ], + ], ++ [ ++ [ ++ 'name' => 'foo/bar', ++ 'bin' => ['bin/foo', '../../../../etc/evil', 'nested/../../escape'], ++ ], ++ [ ++ 'bin.1 : invalid value (../../../../etc/evil), must not contain a ".." path component', ++ 'bin.2 : invalid value (nested/../../escape), must not contain a ".." path component', ++ ], ++ ], ++ [ ++ [ ++ 'name' => 'foo/bar', ++ 'bin' => '../escape', ++ ], ++ [ ++ 'bin : invalid value (../escape), must not contain a ".." path component', ++ ], ++ ], + [ + [ + 'name' => 'foo/bar', +@@ -575,4 +596,72 @@ class ValidatingArrayLoaderTest extends TestCase + ], + ]; + } ++ ++ public function testValidatePackageAllowsValidPackages(): void ++ { ++ $package = self::getPackage('vendor/package', '1.0.0'); ++ $package->setSourceType('git'); ++ $package->setSourceUrl('https://example.org/vendor/package.git'); ++ $package->setSourceReference('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); ++ $package->setDistType('zip'); ++ $package->setDistUrl('https://example.org/vendor/package.zip'); ++ $package->setDistReference('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); ++ $package->setBinaries(['bin/foo', 'console', 'some.bin']); ++ ValidatingArrayLoader::validatePackage($package); ++ ++ // platform packages are accepted as-is, and the root package is skipped entirely ++ ValidatingArrayLoader::validatePackage(self::getPackage('php', '8.2.0')); ++ ValidatingArrayLoader::validatePackage(self::getRootPackage()); ++ ++ $this->expectNotToPerformAssertions(); ++ } ++ ++ /** ++ * @dataProvider provideMaliciousPackages ++ */ ++ public function testValidatePackageRejectsMaliciousMetadata(PackageInterface $package, string $expectedMessage): void ++ { ++ $this->expectException(SecurityException::class); ++ $this->expectExceptionMessage($expectedMessage); ++ ValidatingArrayLoader::validatePackage($package); ++ } ++ ++ /** ++ * @return array<string, array{PackageInterface, string}> ++ */ ++ public static function provideMaliciousPackages(): array ++ { ++ $badName = self::getPackage('--evil/pkg', '1.0.0'); ++ ++ $badSourceUrl = self::getPackage('vendor/pkg', '1.0.0'); ++ $badSourceUrl->setSourceType('git'); ++ $badSourceUrl->setSourceUrl('--upload-pack=touch /tmp/pwned'); ++ $badSourceUrl->setSourceReference('main'); ++ ++ $badSourceReference = self::getPackage('vendor/pkg', '1.0.0'); ++ $badSourceReference->setSourceType('git'); ++ $badSourceReference->setSourceUrl('https://example.org/vendor/pkg.git'); ++ $badSourceReference->setSourceReference('--upload-pack=touch /tmp/pwned'); ++ ++ $badDistUrl = self::getPackage('vendor/pkg', '1.0.0'); ++ $badDistUrl->setDistType('zip'); ++ $badDistUrl->setDistUrl('-oProxyCommand=touch /tmp/pwned'); ++ ++ $badDistReference = self::getPackage('vendor/pkg', '1.0.0'); ++ $badDistReference->setDistType('zip'); ++ $badDistReference->setDistUrl('https://example.org/vendor/pkg.zip'); ++ $badDistReference->setDistReference('--evil'); ++ ++ $badBin = self::getPackage('vendor/pkg', '1.0.0'); ++ $badBin->setBinaries(['bin/ok', '../../../../escape-target.txt']); ++ ++ return [ ++ 'invalid name' => [$badName, 'Invalid package found during dependency resolution'], ++ 'dash source.url' => [$badSourceUrl, 'vendor/pkg has an invalid source.url'], ++ 'dash source.reference' => [$badSourceReference, 'vendor/pkg has an invalid source.reference'], ++ 'dash dist.url' => [$badDistUrl, 'vendor/pkg has an invalid dist.url'], ++ 'dash dist.reference' => [$badDistReference, 'vendor/pkg has an invalid dist.reference'], ++ 'bin path traversal' => [$badBin, 'vendor/pkg has an invalid bin ../../../../escape-target.txt, it must not contain ".." path segments'], ++ ]; ++ } + } diff -Nru composer-2.8.8/debian/patches/0022-Modernize-PHPUnit-syntax.patch composer-2.8.8/debian/patches/0022-Modernize-PHPUnit-syntax.patch --- composer-2.8.8/debian/patches/0022-Modernize-PHPUnit-syntax.patch 2026-05-14 10:37:03.000000000 +0200 +++ composer-2.8.8/debian/patches/0022-Modernize-PHPUnit-syntax.patch 1970-01-01 01:00:00.000000000 +0100 @@ -1,38 +0,0 @@ -From: =?utf-8?q?David_Pr=C3=A9vot?= <[email protected]> -Date: Fri, 29 Aug 2025 08:31:05 +0200 -Subject: Modernize PHPUnit syntax - ---- - tests/Composer/Test/IO/BaseIOTest.php | 9 +++------ - 1 file changed, 3 insertions(+), 6 deletions(-) - -diff --git a/tests/Composer/Test/IO/BaseIOTest.php b/tests/Composer/Test/IO/BaseIOTest.php -index b899d86..e22d8ea 100644 ---- a/tests/Composer/Test/IO/BaseIOTest.php -+++ b/tests/Composer/Test/IO/BaseIOTest.php -@@ -15,12 +15,11 @@ namespace Composer\Test\IO; - use Composer\Config; - use Composer\IO\BufferIO; - use Composer\Test\TestCase; -+use PHPUnit\Framework\Attributes\DataProvider; - - class BaseIOTest extends TestCase - { -- /** -- * @dataProvider provideValidGithubTokens -- */ -+ #[DataProvider('provideValidGithubTokens')] - public function testLoadConfigurationAcceptsValidGithubToken(string $token): void - { - $io = new BufferIO(); -@@ -55,9 +54,7 @@ class BaseIOTest extends TestCase - ]; - } - -- /** -- * @dataProvider provideUrlBreakingGithubTokens -- */ -+ #[DataProvider('provideUrlBreakingGithubTokens')] - public function testLoadConfigurationRejectsTokenWithUrlBreakingCharacters(string $token, string $offending): void - { - $io = new BufferIO(); diff -Nru composer-2.8.8/debian/patches/0023-Relax-token-validation-on-input-and-hide-more-things.patch composer-2.8.8/debian/patches/0023-Relax-token-validation-on-input-and-hide-more-things.patch --- composer-2.8.8/debian/patches/0023-Relax-token-validation-on-input-and-hide-more-things.patch 1970-01-01 01:00:00.000000000 +0100 +++ composer-2.8.8/debian/patches/0023-Relax-token-validation-on-input-and-hide-more-things.patch 2026-08-29 13:39:42.000000000 +0200 @@ -0,0 +1,131 @@ +From: Jordi Boggiano <[email protected]> +Date: Thu, 28 May 2026 11:01:37 +0200 +Subject: Relax token validation on input, + and hide more things on output (#12886) + +--- + src/Composer/IO/BaseIO.php | 6 ----- + src/Composer/Util/ProcessExecutor.php | 3 +++ + src/Composer/Util/Url.php | 3 +++ + tests/Composer/Test/IO/BaseIOTest.php | 42 ----------------------------------- + tests/Composer/Test/Util/UrlTest.php | 4 ++++ + 5 files changed, 10 insertions(+), 48 deletions(-) + +diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php +index 9650c96..8d3bc36 100644 +--- a/src/Composer/IO/BaseIO.php ++++ b/src/Composer/IO/BaseIO.php +@@ -134,12 +134,6 @@ abstract class BaseIO implements IOInterface + $config->merge(['config' => ['github-domains' => array_merge($config->get('github-domains'), [$domain])]], 'implicit-due-to-auth'); + } + +- // allowed chars for GH tokens are from https://github.blog/changelog/2021-03-04-authentication-token-format-updates/ +- // plus dots which were at some point used for GH app integration tokens +- if (!Preg::isMatch('{^[.A-Za-z0-9_-]+$}', $token)) { +- throw new \UnexpectedValueException('Your github oauth token for '.$domain.' contains invalid characters.'); +- } +- + $this->checkAndSetAuthentication($domain, $token, 'x-oauth-basic'); + } + +diff --git a/src/Composer/Util/ProcessExecutor.php b/src/Composer/Util/ProcessExecutor.php +index 91a5c40..ef8d4f2 100644 +--- a/src/Composer/Util/ProcessExecutor.php ++++ b/src/Composer/Util/ProcessExecutor.php +@@ -490,6 +490,9 @@ class ProcessExecutor + if (Preg::isMatch('{^[a-f0-9]{12,}$}', $m['user'])) { + return '://***:***@'; + } ++ if (strlen($m['user']) >= 12) { ++ return '://'.substr($m['user'], 0, 8).'***:***@'; ++ } + + return '://'.$m['user'].':***@'; + }, $commandString); +diff --git a/src/Composer/Util/Url.php b/src/Composer/Util/Url.php +index 7e615fe..d210e7a 100644 +--- a/src/Composer/Util/Url.php ++++ b/src/Composer/Util/Url.php +@@ -114,6 +114,9 @@ class Url + if (Preg::isMatch(GitHub::GITHUB_TOKEN_REGEX, $m['user'])) { + return $m['prefix'].'***:***@'; + } ++ if (strlen($m['user']) >= 12) { ++ return $m['prefix'].substr($m['user'], 0, 8).'***:***@'; ++ } + + return $m['prefix'].$m['user'].':***@'; + }, $url); +diff --git a/tests/Composer/Test/IO/BaseIOTest.php b/tests/Composer/Test/IO/BaseIOTest.php +index b899d86..7219837 100644 +--- a/tests/Composer/Test/IO/BaseIOTest.php ++++ b/tests/Composer/Test/IO/BaseIOTest.php +@@ -54,46 +54,4 @@ class BaseIOTest extends TestCase + ], + ]; + } +- +- /** +- * @dataProvider provideUrlBreakingGithubTokens +- */ +- public function testLoadConfigurationRejectsTokenWithUrlBreakingCharacters(string $token, string $offending): void +- { +- $io = new BufferIO(); +- $config = new Config(false); +- $config->merge(['config' => ['github-oauth' => ['github.com' => $token]]]); +- +- try { +- $io->loadConfiguration($config); +- self::fail('Expected loadConfiguration to reject token containing '.$offending); +- } catch (\UnexpectedValueException $e) { +- // Defect #1: the rejected token must not be echoed back into the +- // exception message — Symfony Console renders it to stderr and CI +- // log shippers / GitHub Actions secret masking do not reliably +- // strip it from the framed error block. +- self::assertStringNotContainsString( +- $token, +- $e->getMessage(), +- 'Exception message must not leak the rejected token value.' +- ); +- } +- } +- +- /** @return array<string, array{string, string}> */ +- public static function provideUrlBreakingGithubTokens(): array +- { +- return [ +- 'contains @ (userinfo separator)' => ['[email protected]', '@'], +- 'contains : (basic-auth user:pass split)' => ['ghp_AAAA:extra', ':'], +- 'contains / (path separator)' => ['ghp_AAA/BBB', '/'], +- 'contains backslash' => ['ghp_AAA\\BBB', '\\'], +- 'contains ? (query separator)' => ['ghp_AAA?x=1', '?'], +- 'contains # (fragment)' => ['ghp_AAA#frag', '#'], +- 'contains space' => ['ghp_AAA BBB', 'space'], +- 'contains tab' => ["ghp_AAA\tBBB", 'tab'], +- 'contains CR' => ["ghp_AAA\rBBB", 'CR'], +- 'contains LF (header injection)' => ["ghp_AAA\nX-Evil: 1", 'LF'], +- ]; +- } + } +diff --git a/tests/Composer/Test/Util/UrlTest.php b/tests/Composer/Test/Util/UrlTest.php +index 5d55dfa..fb54143 100644 +--- a/tests/Composer/Test/Util/UrlTest.php ++++ b/tests/Composer/Test/Util/UrlTest.php +@@ -81,6 +81,8 @@ class UrlTest extends TestCase + ['https://example.org/foo/bar?foo=bar&access_token=***', 'https://example.org/foo/bar?foo=bar&access_token=abcdef'], + ['https://***:***@github.com/acme/repo', 'https://ghp_1234567890abcdefghijklmnopqrstuvwxyzAB:[email protected]/acme/repo'], + ['https://***:***@github.com/acme/repo', 'https://github_pat_1234567890abcdefghijkl_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW:[email protected]/acme/repo'], ++ ['http://abcdefgh***:***@example.org:123/', 'http://abcdefghijkl:[email protected]:123/'], ++ ['https://abcdefgh***:***@example.org:123/', 'https://abcdefghijklmnop:[email protected]:123/'], + // without scheme + ['foo:***@example.org/', 'foo:[email protected]/'], + ['[email protected]/', '[email protected]/'], +@@ -89,6 +91,8 @@ class UrlTest extends TestCase + ['foo:***@example.org:123/', 'foo:[email protected]:123/'], + ['example.org/foo/bar?access_token=***', 'example.org/foo/bar?access_token=abcdef'], + ['example.org/foo/bar?foo=bar&access_token=***', 'example.org/foo/bar?foo=bar&access_token=abcdef'], ++ ['abcdefgh***:***@example.org:123/', 'abcdefghijkl:[email protected]:123/'], ++ ['abcdefgh***:***@example.org:123/', 'abcdefghijklmnop:[email protected]:123/'], + ]; + } + } diff -Nru composer-2.8.8/debian/patches/0024-Merge-commit-from-fork.patch composer-2.8.8/debian/patches/0024-Merge-commit-from-fork.patch --- composer-2.8.8/debian/patches/0024-Merge-commit-from-fork.patch 1970-01-01 01:00:00.000000000 +0100 +++ composer-2.8.8/debian/patches/0024-Merge-commit-from-fork.patch 2026-08-29 13:39:42.000000000 +0200 @@ -0,0 +1,280 @@ +From: Jordi Boggiano <[email protected]> +Date: Wed, 1 Jul 2026 10:54:03 +0200 +Subject: Merge commit from fork + +* Sanitize URL-embedded usernames/tokens in verbose output + +AuthHelper::addAuthenticationOptions() wrote the HTTP-Basic username +verbatim to the -vvv debug log. When a token is embedded in the URL user +slot (e.g. https://ghp_TOKEN@host/...), it leaked in plaintext. + +Url::sanitize() and ProcessExecutor only matched the user:password@ form, +so the documented user@ (no-password) form was not sanitized either. + +Consolidate the masking decision into Url::sanitizeUsername(): GitHub +tokens and long hex strings keep only their first 3 chars, other 12char+ +values keep their first 8, short values and well-known non-secret markers +(x-token-auth, gitlab-ci-token, ...) are shown verbatim. The marker list +is now a shared Url::NON_SECRET_CREDENTIALS constant reused by +Git::maskCredentials. AuthHelper's basic-auth display now runs through it. + +* Mask long (12char+) usernames to first 3 chars too + +Previously non-token 12char+ usernames kept their first 8 chars while +GitHub tokens kept only 3. Treat both the same: keep only the first 3 +chars. The two branches now collapse into one condition. + +Origin: backport, https://github.com/composer/composer/commit/8887ad76fbd830cb1861a2b1fd8ead78ed1fa1ec +Bug: https://github.com/composer/composer/security/advisories/GHSA-g6xq-892h-64w3 +Bug-Debian: https://security-tracker.debian.org/tracker/CVE-2026-59947 + +p +--- + src/Composer/Util/AuthHelper.php | 2 +- + src/Composer/Util/Git.php | 2 +- + src/Composer/Util/ProcessExecutor.php | 16 +++------ + src/Composer/Util/Url.php | 43 +++++++++++++++++++----- + tests/Composer/Test/Util/AuthHelperTest.php | 43 ++++++++++++++++++++++++ + tests/Composer/Test/Util/ProcessExecutorTest.php | 6 ++-- + tests/Composer/Test/Util/UrlTest.php | 27 ++++++++++----- + 7 files changed, 108 insertions(+), 31 deletions(-) + +diff --git a/src/Composer/Util/AuthHelper.php b/src/Composer/Util/AuthHelper.php +index 3f03126..9ec9bbd 100644 +--- a/src/Composer/Util/AuthHelper.php ++++ b/src/Composer/Util/AuthHelper.php +@@ -267,7 +267,7 @@ class AuthHelper + } else { + $authStr = base64_encode($auth['username'] . ':' . $auth['password']); + $headers[] = 'Authorization: Basic '.$authStr; +- $authenticationDisplayMessage = 'Using HTTP basic authentication with username "' . $auth['username'] . '"'; ++ $authenticationDisplayMessage = 'Using HTTP basic authentication with username "' . Url::sanitizeUsername((string) $auth['username']) . '"'; + } + + if ($authenticationDisplayMessage && (!isset($this->displayedOriginAuthentications[$origin]) || $this->displayedOriginAuthentications[$origin] !== $authenticationDisplayMessage)) { +diff --git a/src/Composer/Util/Git.php b/src/Composer/Util/Git.php +index e340c1b..d43c846 100644 +--- a/src/Composer/Util/Git.php ++++ b/src/Composer/Util/Git.php +@@ -612,7 +612,7 @@ class Git + $maskedCredentials = []; + + foreach ($credentials as $credential) { +- if (in_array($credential, ['private-token', 'x-token-auth', 'oauth2', 'gitlab-ci-token', 'x-oauth-basic'])) { ++ if (in_array($credential, Url::NON_SECRET_CREDENTIALS, true)) { + $maskedCredentials[] = $credential; + } elseif (strlen($credential) > 6) { + $maskedCredentials[] = substr($credential, 0, 3) . '...' . substr($credential, -3); +diff --git a/src/Composer/Util/ProcessExecutor.php b/src/Composer/Util/ProcessExecutor.php +index ef8d4f2..e216e6a 100644 +--- a/src/Composer/Util/ProcessExecutor.php ++++ b/src/Composer/Util/ProcessExecutor.php +@@ -482,19 +482,13 @@ class ProcessExecutor + } + + $commandString = is_string($command) ? $command : implode(' ', array_map(self::class.'::escape', $command)); +- $safeCommand = Preg::replaceCallback('{://(?P<user>[^:/\s]+):(?P<password>[^@\s/]+)@}i', static function ($m): string { +- // if the username looks like a long (12char+) hex string, or a modern github token (e.g. ghp_xxx, github_pat_xxx) we obfuscate that +- if (Preg::isMatch(GitHub::GITHUB_TOKEN_REGEX, $m['user'])) { +- return '://***:***@'; +- } +- if (Preg::isMatch('{^[a-f0-9]{12,}$}', $m['user'])) { +- return '://***:***@'; +- } +- if (strlen($m['user']) >= 12) { +- return '://'.substr($m['user'], 0, 8).'***:***@'; ++ $safeCommand = Preg::replaceCallback('{://(?P<user>[^:/\s@]+)(?::(?P<password>[^@\s/]+))?@}i', static function ($m): string { ++ $user = Url::sanitizeUsername($m['user']); ++ if (($m['password'] ?? '') !== '') { ++ return '://'.$user.':***@'; + } + +- return '://'.$m['user'].':***@'; ++ return '://'.$user.'@'; + }, $commandString); + $safeCommand = Preg::replace("{--password (.*[^\\\\]\') }", '--password \'***\' ', $safeCommand); + $this->io->writeError('Executing'.($async ? ' async' : '').' command ('.($cwd ?: 'CWD').'): '.$safeCommand); +diff --git a/src/Composer/Util/Url.php b/src/Composer/Util/Url.php +index d210e7a..28a987f 100644 +--- a/src/Composer/Util/Url.php ++++ b/src/Composer/Util/Url.php +@@ -20,6 +20,15 @@ use Composer\Pcre\Preg; + */ + class Url + { ++ /** ++ * Well-known credential markers used in the user or password slot of URLs by various services ++ * (e.g. Bitbucket's x-token-auth or GitLab's gitlab-ci-token). These are not secret and are ++ * therefore safe to display verbatim rather than obfuscating them. ++ * ++ * @var list<string> ++ */ ++ public const NON_SECRET_CREDENTIALS = ['private-token', 'x-token-auth', 'oauth2', 'gitlab-ci-token', 'x-oauth-basic']; ++ + /** + * @param non-empty-string $url + * @return non-empty-string the updated URL +@@ -109,18 +118,36 @@ class Url + // e.g. https://api.github.com/repositories/9999999999?access_token=github_token + $url = Preg::replace('{([&?]access_token=)[^&]+}', '$1***', $url); + +- $url = Preg::replaceCallback('{^(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+):(?P<password>[^@\s/]+)@}i', static function ($m): string { +- // if the username looks like a long (12char+) hex string, or a modern github token (e.g. ghp_xxx, github_pat_xxx) we obfuscate that +- if (Preg::isMatch(GitHub::GITHUB_TOKEN_REGEX, $m['user'])) { +- return $m['prefix'].'***:***@'; +- } +- if (strlen($m['user']) >= 12) { +- return $m['prefix'].substr($m['user'], 0, 8).'***:***@'; ++ $url = Preg::replaceCallback('{^(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+)(?::(?P<password>[^@\s/]+))?@}i', static function ($m): string { ++ $user = self::sanitizeUsername($m['user']); ++ if (($m['password'] ?? '') !== '') { ++ return $m['prefix'].$user.':***@'; + } + +- return $m['prefix'].$m['user'].':***@'; ++ return $m['prefix'].$user.'@'; + }, $url); + + return $url; + } ++ ++ /** ++ * Returns a display-safe version of the user/token part of a URL's userinfo. ++ * ++ * Modern GitHub tokens (e.g. ghp_xxx, github_pat_xxx) and any other long (12char+) value keep ++ * only their first 3 chars (enough to tell which token is in use); short values (typical ++ * account names) and well-known non-secret markers are returned unchanged. ++ */ ++ public static function sanitizeUsername(string $user): string ++ { ++ // well-known non-secret credential markers (e.g. Bitbucket's x-token-auth) are safe to show verbatim ++ if (in_array($user, self::NON_SECRET_CREDENTIALS, true)) { ++ return $user; ++ } ++ // GitHub tokens and any other long (12char+) value keep only their first 3 chars ++ if (Preg::isMatch(GitHub::GITHUB_TOKEN_REGEX, $user) || strlen($user) >= 12) { ++ return substr($user, 0, 3).'***'; ++ } ++ ++ return $user; ++ } + } +diff --git a/tests/Composer/Test/Util/AuthHelperTest.php b/tests/Composer/Test/Util/AuthHelperTest.php +index 2d5a75c..e8f03c9 100644 +--- a/tests/Composer/Test/Util/AuthHelperTest.php ++++ b/tests/Composer/Test/Util/AuthHelperTest.php +@@ -322,6 +322,49 @@ class AuthHelperTest extends TestCase + ); + } + ++ public function testAddAuthenticationHeaderWithBasicHttpAuthenticationMasksTokenUsername(): void ++ { ++ $origin = 'some-api.url.com'; ++ $url = 'https://some-api.url.com'; ++ $auth = array( ++ 'username' => 'ghp_1234567890abcdefghijklmnopqrstuvwxyzAB', ++ 'password' => 'x-oauth-basic', ++ ); ++ ++ $headers = array( ++ 'Accept-Encoding: gzip', ++ 'Connection: close', ++ ); ++ ++ $this->expectsAuthentication($origin, $auth); ++ ++ $this->config->expects($this->once()) ++ ->method('get') ++ ->with('gitlab-domains') ++ ->willReturn(array($origin)); ++ ++ // the displayed username must be obfuscated so a token in the user slot does not leak to verbose logs ++ // (only the first 3 chars are kept, enough to tell which kind of token is in use) ++ $this->io->expects($this->once()) ++ ->method('writeError') ++ ->with( ++ 'Using HTTP basic authentication with username "ghp***"', ++ true, ++ IOInterface::DEBUG ++ ); ++ ++ // the actual auth header must still contain the real, un-obfuscated credentials ++ $expectedHeaders = array_merge( ++ $headers, ++ array('Authorization: Basic ' . base64_encode($auth['username'] . ':' . $auth['password'])) ++ ); ++ ++ $this->assertSame( ++ $expectedHeaders, ++ $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ++ ); ++ } ++ + #[DataProvider('bitbucketPublicUrlProvider')] + public function testIsPublicBitBucketDownloadWithBitbucketPublicUrl(string $url): void + { +diff --git a/tests/Composer/Test/Util/ProcessExecutorTest.php b/tests/Composer/Test/Util/ProcessExecutorTest.php +index da27847..2d94e06 100644 +--- a/tests/Composer/Test/Util/ProcessExecutorTest.php ++++ b/tests/Composer/Test/Util/ProcessExecutorTest.php +@@ -81,8 +81,10 @@ class ProcessExecutorTest extends TestCase + return [ + ['echo https://foo:[email protected]/', 'echo https://foo:***@example.org/'], + ['echo http://[email protected]', 'echo http://[email protected]'], +- ['echo http://abcdef1234567890234578:[email protected]/', 'echo http://***:***@github.com/'], +- ['echo http://github_pat_1234567890abcdefghijkl_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW:[email protected]/', 'echo http://***:***@github.com/'], ++ ['echo http://abcdef1234567890234578:[email protected]/', 'echo http://abc***:***@github.com/'], ++ ['echo http://github_pat_1234567890abcdefghijkl_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW:[email protected]/', 'echo http://git***:***@github.com/'], ++ ['echo http://[email protected]/', 'echo http://ghp***@github.com/'], ++ ['echo http://[email protected]/', 'echo http://abc***@github.com/'], + ["svn ls --verbose --non-interactive --username 'foo' --password 'bar' 'https://foo.example.org/svn/'", "svn ls --verbose --non-interactive --username 'foo' --password '***' 'https://foo.example.org/svn/'"], + ["svn ls --verbose --non-interactive --username 'foo' --password 'bar \'bar' 'https://foo.example.org/svn/'", "svn ls --verbose --non-interactive --username 'foo' --password '***' 'https://foo.example.org/svn/'"], + ]; +diff --git a/tests/Composer/Test/Util/UrlTest.php b/tests/Composer/Test/Util/UrlTest.php +index fb54143..4f16104 100644 +--- a/tests/Composer/Test/Util/UrlTest.php ++++ b/tests/Composer/Test/Util/UrlTest.php +@@ -75,24 +75,35 @@ class UrlTest extends TestCase + ['https://foo:***@example.org/', 'https://foo:[email protected]/'], + ['https://[email protected]/', 'https://[email protected]/'], + ['https://example.org/', 'https://example.org/'], +- ['http://***:***@example.org', 'http://10a8f08e8d7b7b9:[email protected]'], ++ ['http://10a***:***@example.org', 'http://10a8f08e8d7b7b9:[email protected]'], + ['https://foo:***@example.org:123/', 'https://foo:[email protected]:123/'], + ['https://example.org/foo/bar?access_token=***', 'https://example.org/foo/bar?access_token=abcdef'], + ['https://example.org/foo/bar?foo=bar&access_token=***', 'https://example.org/foo/bar?foo=bar&access_token=abcdef'], +- ['https://***:***@github.com/acme/repo', 'https://ghp_1234567890abcdefghijklmnopqrstuvwxyzAB:[email protected]/acme/repo'], +- ['https://***:***@github.com/acme/repo', 'https://github_pat_1234567890abcdefghijkl_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW:[email protected]/acme/repo'], +- ['http://abcdefgh***:***@example.org:123/', 'http://abcdefghijkl:[email protected]:123/'], +- ['https://abcdefgh***:***@example.org:123/', 'https://abcdefghijklmnop:[email protected]:123/'], ++ ['https://ghp***:***@github.com/acme/repo', 'https://ghp_1234567890abcdefghijklmnopqrstuvwxyzAB:[email protected]/acme/repo'], ++ ['https://git***:***@github.com/acme/repo', 'https://github_pat_1234567890abcdefghijkl_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVW:[email protected]/acme/repo'], ++ ['http://abc***:***@example.org:123/', 'http://abcdefghijkl:[email protected]:123/'], ++ ['https://abc***:***@example.org:123/', 'https://abcdefghijklmnop:[email protected]:123/'], ++ // token/long username in the user slot without a password (e.g. https://TOKEN@host) ++ ['https://ghp***@github.com/acme/repo', 'https://[email protected]/acme/repo'], ++ ['https://git***@github.com/acme/repo', 'https://github_pat_1234567890abcdefghijkl_1234567890abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrst...@github.com/acme/repo'], ++ ['http://10a***@example.org', 'http://[email protected]'], ++ ['https://abc***@example.org:123/', 'https://[email protected]:123/'], ++ // well-known non-secret credential markers are shown verbatim even though they are 12char+ ++ ['https://x-token-auth:***@bitbucket.org/acme/repo', 'https://x-token-auth:[email protected]/acme/repo'], ++ ['https://gitlab-ci-token:***@gitlab.example.org/', 'https://gitlab-ci-token:[email protected]/'], + // without scheme + ['foo:***@example.org/', 'foo:[email protected]/'], + ['[email protected]/', '[email protected]/'], + ['example.org/', 'example.org/'], +- ['***:***@example.org', '10a8f08e8d7b7b9:[email protected]'], ++ ['10a***:***@example.org', '10a8f08e8d7b7b9:[email protected]'], + ['foo:***@example.org:123/', 'foo:[email protected]:123/'], + ['example.org/foo/bar?access_token=***', 'example.org/foo/bar?access_token=abcdef'], + ['example.org/foo/bar?foo=bar&access_token=***', 'example.org/foo/bar?foo=bar&access_token=abcdef'], +- ['abcdefgh***:***@example.org:123/', 'abcdefghijkl:[email protected]:123/'], +- ['abcdefgh***:***@example.org:123/', 'abcdefghijklmnop:[email protected]:123/'], ++ ['abc***:***@example.org:123/', 'abcdefghijkl:[email protected]:123/'], ++ ['abc***:***@example.org:123/', 'abcdefghijklmnop:[email protected]:123/'], ++ ['ghp***@github.com/acme/repo', '[email protected]/acme/repo'], ++ ['10a***@example.org', '[email protected]'], ++ ['abc***@example.org:123/', '[email protected]:123/'], + ]; + } + } diff -Nru composer-2.8.8/debian/patches/0025-Merge-commit-from-fork.patch composer-2.8.8/debian/patches/0025-Merge-commit-from-fork.patch --- composer-2.8.8/debian/patches/0025-Merge-commit-from-fork.patch 1970-01-01 01:00:00.000000000 +0100 +++ composer-2.8.8/debian/patches/0025-Merge-commit-from-fork.patch 2026-08-29 13:39:42.000000000 +0200 @@ -0,0 +1,234 @@ +From: Jordi Boggiano <[email protected]> +Date: Thu, 27 Aug 2026 13:07:20 +0200 +Subject: Merge commit from fork + +* Prevent bin symlinks from escaping the package directory (GHSA-96h3-5x6v-m776) + +The GHSA-gjfg-22fp-rrxx fix rejects literal ".." components in bin metadata, +but that only inspects the metadata string. A malicious package can declare a +clean bin (e.g. bin/pwn) while shipping bin/pwn as a symlink to +../../../../victim.sh. BinaryInstaller would follow the symlink and chmod / +proxy a file outside the package install directory. + +Verify realpath($binPath) stays within realpath($installPath) before using the +bin, skipping it with a warning otherwise. Legitimate in-package symlinks and +path-repository symlink installs still resolve inside the package and are +unaffected. + +* Also guard the FileDownloader bin chmod against escaping paths (GHSA-96h3-5x6v-m776) + +FileDownloader::install() chmods the package's bins itself, for single-file dists +which cannot carry a mode. That loop never went through +ValidatingArrayLoader::validatePackage(), and composer reinstall builds its +operations straight from installed.json without ever solving, so a ".." bin there +still reached the chmod even with the BinaryInstaller check in place. + +Extract the containment check into BinaryInstaller::isBinPathInsidePackage() and +use it in both sinks. + +Origin: backport, https://github.com/composer/composer/commit/53b8bb4c24697b2f00a18cf1ef35a10392701b89 +Bug: https://github.com/composer/composer/security/advisories/GHSA-96h3-5x6v-m776 +Bug-Debian: https://security-tracker.debian.org/tracker/CVE-2026-59944 +--- + src/Composer/Downloader/FileDownloader.php | 12 ++++- + src/Composer/Installer/BinaryInstaller.php | 27 ++++++++++ + .../Test/Downloader/FileDownloaderTest.php | 39 ++++++++++++++ + .../Test/Installer/BinaryInstallerTest.php | 59 ++++++++++++++++++++++ + 4 files changed, 135 insertions(+), 2 deletions(-) + +diff --git a/src/Composer/Downloader/FileDownloader.php b/src/Composer/Downloader/FileDownloader.php +index 5f3b242..bd3b018 100644 +--- a/src/Composer/Downloader/FileDownloader.php ++++ b/src/Composer/Downloader/FileDownloader.php +@@ -26,6 +26,7 @@ use Composer\Plugin\PluginEvents; + use Composer\Plugin\PostFileDownloadEvent; + use Composer\Plugin\PreFileDownloadEvent; + use Composer\EventDispatcher\EventDispatcher; ++use Composer\Installer\BinaryInstaller; + use Composer\Util\Filesystem; + use Composer\Util\Http\Response; + use Composer\Util\Platform; +@@ -364,9 +365,16 @@ class FileDownloader implements DownloaderInterface, ChangeReportInterface + // Single files can not have a mode set like files in archives + // so we make sure if the file is a binary that it is executable + foreach ($package->getBinaries() as $bin) { +- if (file_exists($path . '/' . $bin) && !is_executable($path . '/' . $bin)) { +- Silencer::call('chmod', $path . '/' . $bin, 0777 & ~umask()); ++ $binPath = $path . '/' . $bin; ++ if (!file_exists($binPath) || is_executable($binPath)) { ++ continue; + } ++ // a bin resolving outside of the package would let it chmod an arbitrary host file, this ++ // is reported by BinaryInstaller later in the same install (GHSA-96h3-5x6v-m776) ++ if (!BinaryInstaller::isBinPathInsidePackage($path, $binPath)) { ++ continue; ++ } ++ Silencer::call('chmod', $binPath, 0777 & ~umask()); + } + + return \React\Promise\resolve(null); +diff --git a/src/Composer/Installer/BinaryInstaller.php b/src/Composer/Installer/BinaryInstaller.php +index 9211325..4c3cb46 100644 +--- a/src/Composer/Installer/BinaryInstaller.php ++++ b/src/Composer/Installer/BinaryInstaller.php +@@ -71,6 +71,13 @@ class BinaryInstaller + $this->io->writeError(' <warning>Skipped installation of bin '.$bin.' for package '.$package->getName().': found a directory at that path</warning>'); + continue; + } ++ // A malicious package can pass the ".." bin metadata check yet ship the bin as a symlink ++ // pointing outside the package (e.g. to ../../../victim.sh), following it here would let ++ // the package chmod/proxy an arbitrary host file (GHSA-96h3-5x6v-m776). ++ if (!self::isBinPathInsidePackage($installPath, $binPath)) { ++ $this->io->writeError(' <warning>Skipped installation of bin '.$bin.' for package '.$package->getName().': the bin resolves to a path outside of the package directory</warning>'); ++ continue; ++ } + if (!$this->filesystem->isAbsolutePath($binPath)) { + // in case a custom installer returned a relative path for the + // $package, we can now safely turn it into a absolute path (as we +@@ -147,6 +154,26 @@ class BinaryInstaller + return 'php'; + } + ++ /** ++ * Checks that a bin file resolves to a path inside the package's own install directory ++ * ++ * A bin escaping the package, either via ".." metadata or by being a symlink pointing out of it, ++ * would let the package chmod/proxy an arbitrary host file, see GHSA-gjfg-22fp-rrxx and ++ * GHSA-96h3-5x6v-m776. ++ */ ++ public static function isBinPathInsidePackage(string $installPath, string $binPath): bool ++ { ++ $realBinPath = realpath($binPath); ++ $realInstallPath = realpath($installPath); ++ ++ // fail closed if either path cannot be resolved ++ if (false === $realBinPath || false === $realInstallPath) { ++ return false; ++ } ++ ++ return strpos($realBinPath, $realInstallPath.DIRECTORY_SEPARATOR) === 0; ++ } ++ + /** + * @return string[] + */ +diff --git a/tests/Composer/Test/Downloader/FileDownloaderTest.php b/tests/Composer/Test/Downloader/FileDownloaderTest.php +index 560406d..cc44b1a 100644 +--- a/tests/Composer/Test/Downloader/FileDownloaderTest.php ++++ b/tests/Composer/Test/Downloader/FileDownloaderTest.php +@@ -86,6 +86,45 @@ class FileDownloaderTest extends TestCase + } + } + ++ public function testInstallDoesNotChmodBinOutsideOfPackage(): void ++ { ++ $rootDir = self::getUniqueTmpDirectory(); ++ $vendorDir = $rootDir.'/vendor'; ++ $path = $vendorDir.'/attacker/pkg'; ++ ++ $package = self::getPackage(); ++ $package->setDistUrl('http://example.com/script.js'); ++ // a ".." bin which never went through ValidatingArrayLoader::validatePackage(), as is the ++ // case for composer reinstall which builds its operations straight from installed.json ++ $package->setBinaries(['../../../victim.sh']); ++ ++ $victim = $rootDir.'/victim.sh'; ++ file_put_contents($victim, "#!/bin/sh\necho pwned\n"); ++ chmod($victim, 0600); ++ clearstatcache(); ++ $modeBefore = fileperms($victim); ++ ++ $downloader = $this->getDownloader(null, $this->getConfig(['vendor-dir' => $vendorDir])); ++ ++ // seed the downloaded file where install() expects to find it ++ $method = new \ReflectionMethod($downloader, 'getFileName'); ++ (\PHP_VERSION_ID < 80100) and $method->setAccessible(true); ++ $tmpFile = $method->invoke($downloader, $package, $path); ++ $fs = new Filesystem(); ++ $fs->ensureDirectoryExists(dirname($tmpFile)); ++ file_put_contents($tmpFile, 'downloaded'); ++ ++ try { ++ $downloader->install($package, $path, false); ++ ++ self::assertFileExists($path.'/script.js'); ++ clearstatcache(); ++ self::assertSame($modeBefore, fileperms($victim), 'A bin escaping the package dir must not be chmod\'d'); ++ } finally { ++ $fs->removeDirectory($rootDir); ++ } ++ } ++ + public function testGetFileName(): void + { + $package = self::getPackage(); +diff --git a/tests/Composer/Test/Installer/BinaryInstallerTest.php b/tests/Composer/Test/Installer/BinaryInstallerTest.php +index 981eb5f..b2bcbdb 100644 +--- a/tests/Composer/Test/Installer/BinaryInstallerTest.php ++++ b/tests/Composer/Test/Installer/BinaryInstallerTest.php +@@ -85,6 +85,65 @@ class BinaryInstallerTest extends TestCase + self::assertEquals('success arg', $output); + } + ++ /** ++ * @requires function symlink ++ */ ++ public function testInstallBinaryRejectsSymlinkEscapingPackageDir(): void ++ { ++ $package = $this->createPackageMock(); ++ $package->expects($this->any()) ++ ->method('getBinaries') ++ ->willReturn(['bin/pwn']); ++ ++ // A file outside the package install directory that must not be touched. ++ $victim = $this->rootDir.'/victim.sh'; ++ file_put_contents($victim, "#!/bin/sh\necho pwned\n"); ++ chmod($victim, 0644); ++ clearstatcache(); ++ $modeBefore = fileperms($victim); ++ ++ $installPath = $this->vendorDir.'/attacker/pkg'; ++ $this->ensureDirectoryExistsAndClear($installPath.'/bin'); ++ // bin/pwn is a symlink escaping the package to the victim file (GHSA-96h3-5x6v-m776). ++ if (!@symlink('../../../../victim.sh', $installPath.'/bin/pwn')) { ++ $this->markTestSkipped('Symbolic links are not supported on this platform'); ++ } ++ ++ $installer = new BinaryInstaller($this->io, $this->binDir, 'full', $this->fs); ++ $installer->installBinaries($package, $installPath); ++ ++ self::assertFileDoesNotExist($this->binDir.'/pwn', 'No vendor/bin proxy must be created for an escaping symlink bin'); ++ clearstatcache(); ++ self::assertSame($modeBefore, fileperms($victim), 'A bin symlink escaping the package dir must not be chmod\'d'); ++ } ++ ++ public function testInstallBinaryRejectsTraversingBinPath(): void ++ { ++ // ".." bin metadata can reach BinaryInstaller without passing through the solver-time ++ // ValidatingArrayLoader::validatePackage() check, e.g. via the ensureBinariesPresence() ++ // re-generation loop which reads packages straight from installed.json. ++ $package = $this->createPackageMock(); ++ $package->expects($this->any()) ++ ->method('getBinaries') ++ ->willReturn(['../../../victim.sh']); ++ ++ $victim = $this->rootDir.'/victim.sh'; ++ file_put_contents($victim, "#!/bin/sh\necho pwned\n"); ++ chmod($victim, 0600); ++ clearstatcache(); ++ $modeBefore = fileperms($victim); ++ ++ $installPath = $this->vendorDir.'/attacker/pkg'; ++ $this->ensureDirectoryExistsAndClear($installPath); ++ ++ $installer = new BinaryInstaller($this->io, $this->binDir, 'full', $this->fs); ++ $installer->installBinaries($package, $installPath); ++ ++ self::assertFileDoesNotExist($this->binDir.'/victim.sh', 'No vendor/bin proxy must be created for a traversing bin'); ++ clearstatcache(); ++ self::assertSame($modeBefore, fileperms($victim), 'A bin escaping the package dir via ".." must not be chmod\'d'); ++ } ++ + public static function executableBinaryProvider(): array + { + return [ diff -Nru composer-2.8.8/debian/patches/0026-Merge-commit-from-fork.patch composer-2.8.8/debian/patches/0026-Merge-commit-from-fork.patch --- composer-2.8.8/debian/patches/0026-Merge-commit-from-fork.patch 1970-01-01 01:00:00.000000000 +0100 +++ composer-2.8.8/debian/patches/0026-Merge-commit-from-fork.patch 2026-08-29 13:39:42.000000000 +0200 @@ -0,0 +1,310 @@ +From: Jordi Boggiano <[email protected]> +Date: Thu, 27 Aug 2026 13:08:25 +0200 +Subject: Merge commit from fork + +A package's source.url is handed to the p4 client as P4PORT. The client +documents `rsh:<cmd>`/`jsh:<cmd>` endpoints as "start <cmd> locally and speak +the protocol over its stdio", so a repository operator could publish a package +with source.type=perforce and source.url=rsh:... and get arbitrary command +execution as soon as the victim installed it from source. Argv escaping does +not help here: the execution is a semantic of the p4 client, not of the shell. + +Add Perforce::isValidPort(), which accepts only the documented network forms +([tcp|ssl:][host:]port) and rejects the rsh:/jsh: transports outright, and +enforce it at three points: + +- Perforce::__construct() throws, covering both PerforceDownloader (the attack + path) and PerforceDriver. +- Perforce::checkServerExists() returns false rather than throwing, as it is a + detection probe run against every VCS repository url when no driver matched. +- ValidatingArrayLoader, in load() so `composer validate` and packagist reject + such a package at publish time, and in validatePackage() so a package coming + from a repository or a crafted lock file fails before any p4 call. + +testGenerateP4CommandEscapesPortInjection is replaced by an assertion that the +constructor now rejects that port outright. + +Co-authored-by: Claude Opus 5 (1M context) <[email protected]> + +Origin: backport, https://github.com/composer/composer/commit/0aac50528e83ed635cf788333635897469440220 +Bug: https://github.com/composer/composer/security/advisories/GHSA-rvx4-ffvw-m9q3 +--- + .../Package/Loader/ValidatingArrayLoader.php | 17 +++++ + src/Composer/Util/Perforce.php | 29 ++++++++ + .../Package/Loader/ValidatingArrayLoaderTest.php | 25 ++++++- + tests/Composer/Test/Util/PerforceTest.php | 84 ++++++++++++++++++++-- + 4 files changed, 149 insertions(+), 6 deletions(-) + +diff --git a/src/Composer/Package/Loader/ValidatingArrayLoader.php b/src/Composer/Package/Loader/ValidatingArrayLoader.php +index 7830fd6..5076a9f 100644 +--- a/src/Composer/Package/Loader/ValidatingArrayLoader.php ++++ b/src/Composer/Package/Loader/ValidatingArrayLoader.php +@@ -23,6 +23,7 @@ use Composer\Repository\PlatformRepository; + use Composer\Semver\Constraint\MatchNoneConstraint; + use Composer\Semver\Intervals; + use Composer\Spdx\SpdxLicenses; ++use Composer\Util\Perforce; + + /** + * @author Jordi Boggiano <[email protected]> +@@ -418,6 +419,14 @@ class ValidatingArrayLoader implements LoaderInterface + if (isset($this->config[$srcType]['url']) && Preg::isMatch('{^\s*-}', (string) $this->config[$srcType]['url'])) { + $this->errors[] = $srcType . '.url : must not start with a "-", "'.$this->config[$srcType]['url'].'" given'; + } ++ // a perforce url is passed to the p4 client as P4PORT, where rsh:/jsh: endpoints ++ // mean "run this command locally" (GHSA-rvx4-ffvw-m9q3) ++ if ($srcType === 'source' && ($this->config[$srcType]['type'] ?? null) === 'perforce' ++ && isset($this->config[$srcType]['url']) && is_string($this->config[$srcType]['url']) ++ && !Perforce::isValidPort($this->config[$srcType]['url']) ++ ) { ++ $this->errors[] = $srcType . '.url : invalid Perforce port ("'.$this->config[$srcType]['url'].'"), it must be of the form [tcp|ssl:][host:]port'; ++ } + } + } + +@@ -567,6 +576,14 @@ class ValidatingArrayLoader implements LoaderInterface + } + } + ++ // A perforce source.url ends up as the p4 client's P4PORT, and a "rsh:"/"jsh:" endpoint ++ // there makes the client execute the rest of the value as a local command instead of ++ // connecting to a server (GHSA-rvx4-ffvw-m9q3), so only accept network endpoints. ++ $sourceUrl = $package->getSourceUrl(); ++ if ($package->getSourceType() === 'perforce' && $sourceUrl !== null && !Perforce::isValidPort($sourceUrl)) { ++ throw new SecurityException($package->getName().' has an invalid source.url, it must be a Perforce port of the form [tcp|ssl:][host:]port: '.$sourceUrl); ++ } ++ + // Bin paths are resolved relative to the package install dir and then chmod'd (and + // proxied) by BinaryInstaller. A ".." segment escapes that directory and lets a + // dependency chmod/point at an arbitrary host file (GHSA-gjfg-22fp-rrxx), so reject it. +diff --git a/src/Composer/Util/Perforce.php b/src/Composer/Util/Perforce.php +index 331daf3..aede9d6 100644 +--- a/src/Composer/Util/Perforce.php ++++ b/src/Composer/Util/Perforce.php +@@ -12,6 +12,7 @@ + + namespace Composer\Util; + ++use Composer\Exception\SecurityException; + use Composer\IO\IOInterface; + use Composer\Pcre\Preg; + use Symfony\Component\Process\ExecutableFinder; +@@ -64,6 +65,10 @@ class Perforce + */ + public function __construct($repoConfig, string $port, string $path, ProcessExecutor $process, bool $isWindows, IOInterface $io) + { ++ if (!self::isValidPort($port)) { ++ throw new SecurityException('Invalid Perforce port ('.$port.'), it must be of the form [tcp|ssl:][host:]port'); ++ } ++ + $this->windowsFlag = $isWindows; + $this->p4Port = $port; + $this->initializePath($path); +@@ -82,9 +87,33 @@ class Perforce + + public static function checkServerExists(string $url, ProcessExecutor $processExecutor): bool + { ++ // this is a detection probe which runs against every VCS repository url when no driver ++ // matched yet, so an unusable port is simply "not a perforce server" and not an error ++ if (!self::isValidPort($url)) { ++ return false; ++ } ++ + return 0 === $processExecutor->execute(['p4', '-p', $url, 'info', '-s'], $ignoredOutput); + } + ++ /** ++ * Checks that a P4PORT value is a network endpoint the p4 client can connect to. ++ * ++ * A `rsh:`/`jsh:` P4PORT makes the p4 client run the rest of the value as a local child process ++ * instead of connecting to a server, so a package-controlled source.url must never reach it ++ * (GHSA-rvx4-ffvw-m9q3). Only the documented `[transport:][host:]port` forms are accepted. ++ */ ++ public static function isValidPort(string $url): bool ++ { ++ // rsh/jsh are transport keywords to p4, so "rsh:foo" never parses as host "rsh" port "foo" ++ // and has to be rejected before the shape check below would happily accept it ++ if (Preg::isMatch('{^\s*+(?:rsh|jsh)\s*+:}i', $url)) { ++ return false; ++ } ++ ++ return Preg::isMatch('{^(?:(?:tcp|ssl)(?:4|6|46|64)?:)?(?:\[[0-9a-f:.]++\]|[a-z0-9._][a-z0-9._-]*+)(?::[a-z0-9._][a-z0-9._-]*+)?$}iD', $url); ++ } ++ + /** + * @phpstan-param RepoConfig $repoConfig + */ +diff --git a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +index d1a9215..995ad89 100644 +--- a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php ++++ b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +@@ -444,7 +444,18 @@ class ValidatingArrayLoaderTest extends TestCase + 'require' => ['acme/bar' => '^1.0'] + ], + ['name : must be present'], +- ] ++ ], ++ [ ++ [ ++ 'name' => 'foo/bar', ++ 'source' => [ ++ 'type' => 'perforce', ++ 'url' => 'rsh:touch /tmp/pwned', ++ 'reference' => '//depot/main', ++ ], ++ ], ++ ['source.url : invalid Perforce port ("rsh:touch /tmp/pwned"), it must be of the form [tcp|ssl:][host:]port'], ++ ], + ]); + } + +@@ -609,6 +620,12 @@ class ValidatingArrayLoaderTest extends TestCase + $package->setBinaries(['bin/foo', 'console', 'some.bin']); + ValidatingArrayLoader::validatePackage($package); + ++ $perforcePackage = self::getPackage('vendor/perforce', '1.0.0'); ++ $perforcePackage->setSourceType('perforce'); ++ $perforcePackage->setSourceUrl('ssl:p4.example.org:1666'); ++ $perforcePackage->setSourceReference('//depot/main'); ++ ValidatingArrayLoader::validatePackage($perforcePackage); ++ + // platform packages are accepted as-is, and the root package is skipped entirely + ValidatingArrayLoader::validatePackage(self::getPackage('php', '8.2.0')); + ValidatingArrayLoader::validatePackage(self::getRootPackage()); +@@ -655,6 +672,11 @@ class ValidatingArrayLoaderTest extends TestCase + $badBin = self::getPackage('vendor/pkg', '1.0.0'); + $badBin->setBinaries(['bin/ok', '../../../../escape-target.txt']); + ++ $badPerforceUrl = self::getPackage('vendor/pkg', '1.0.0'); ++ $badPerforceUrl->setSourceType('perforce'); ++ $badPerforceUrl->setSourceUrl('rsh:touch /tmp/pwned'); ++ $badPerforceUrl->setSourceReference('//depot/main'); ++ + return [ + 'invalid name' => [$badName, 'Invalid package found during dependency resolution'], + 'dash source.url' => [$badSourceUrl, 'vendor/pkg has an invalid source.url'], +@@ -662,6 +684,7 @@ class ValidatingArrayLoaderTest extends TestCase + 'dash dist.url' => [$badDistUrl, 'vendor/pkg has an invalid dist.url'], + 'dash dist.reference' => [$badDistReference, 'vendor/pkg has an invalid dist.reference'], + 'bin path traversal' => [$badBin, 'vendor/pkg has an invalid bin ../../../../escape-target.txt, it must not contain ".." path segments'], ++ 'perforce rsh source.url' => [$badPerforceUrl, 'vendor/pkg has an invalid source.url, it must be a Perforce port of the form [tcp|ssl:][host:]port: rsh:touch /tmp/pwned'], + ]; + } + } +diff --git a/tests/Composer/Test/Util/PerforceTest.php b/tests/Composer/Test/Util/PerforceTest.php +index 752079c..8978ce0 100644 +--- a/tests/Composer/Test/Util/PerforceTest.php ++++ b/tests/Composer/Test/Util/PerforceTest.php +@@ -12,6 +12,7 @@ + + namespace Composer\Test\Util; + ++use Composer\Exception\SecurityException; + use Composer\Json\JsonFile; + use Composer\Test\Mock\ProcessExecutorMock; + use Composer\Util\Perforce; +@@ -132,9 +133,11 @@ class PerforceTest extends TestCase + self::assertEquals($expected, $p4Command); + } + +- public function testGenerateP4CommandEscapesPortInjection(): void ++ public function testCreatingPerforceRejectsPortWithShellMetacharacters(): void + { +- $perforce = new Perforce( ++ $this->expectException('Composer\Exception\SecurityException', 'Invalid Perforce port (localhost:1666; touch /tmp/pwned)'); ++ ++ new Perforce( + ['depot' => 'depot', 'branch' => 'branch', 'p4user' => 'user', 'unique_perforce_client_name' => 'TEST'], + 'localhost:1666; touch /tmp/pwned', + 'path', +@@ -142,9 +145,6 @@ class PerforceTest extends TestCase + false, + $this->io + ); +- $command = $perforce->generateP4Command('login -s', false); +- self::assertStringNotContainsString('-p localhost:1666; touch /tmp/pwned', $command); +- self::assertStringContainsString('-p '.ProcessExecutor::escape('localhost:1666; touch /tmp/pwned'), $command); + } + + public function testGenerateP4CommandEscapesUserInjection(): void +@@ -617,6 +617,80 @@ class PerforceTest extends TestCase + self::assertTrue($result); + } + ++ public function testCheckServerExistsRejectsCommandExecutingPort(): void ++ { ++ // no process must be started at all for a rsh:/jsh: endpoint, as the p4 client would ++ // execute it instead of connecting to a server ++ $this->processExecutor->expects([], true); ++ ++ self::assertFalse(Perforce::checkServerExists('rsh:touch /tmp/pwned', $this->processExecutor)); ++ } ++ ++ /** ++ * @dataProvider provideValidPorts ++ */ ++ public function testIsValidPortAcceptsNetworkEndpoints(string $port): void ++ { ++ self::assertTrue(Perforce::isValidPort($port)); ++ } ++ ++ /** ++ * @return array<array{string}> ++ */ ++ public static function provideValidPorts(): array ++ { ++ return [ ++ ['1666'], ++ ['perforce'], ++ ['p4.example.org:1666'], ++ ['perforce.does.exist:port'], ++ ['tcp:p4.example.org:1666'], ++ ['tcp4:p4.example.org:1666'], ++ ['ssl:p4.example.org:1666'], ++ ['SSL:p4.example.org:1666'], ++ ['ssl64:[2001:db8::1]:1666'], ++ ['tcp6:[::1]:1666'], ++ ]; ++ } ++ ++ /** ++ * @dataProvider provideInvalidPorts ++ */ ++ public function testIsValidPortRejectsNonNetworkEndpoints(string $port): void ++ { ++ self::assertFalse(Perforce::isValidPort($port)); ++ } ++ ++ /** ++ * @return array<array{string}> ++ */ ++ public static function provideInvalidPorts(): array ++ { ++ return [ ++ // rsh:/jsh: make the p4 client run the rest of the value as a local command ++ ['rsh:/tmp/evil.sh'], ++ ['rsh:evil'], ++ ['RSH:evil'], ++ [' rsh:evil'], ++ ['jsh:evil'], ++ ['JsH:evil'], ++ ['rsh :evil'], ++ // not valid endpoints either way ++ ['tcp:p4.example.org:1666; touch /tmp/pwned'], ++ ['https://example.org/vendor/pkg.git'], ++ ['-p1666'], ++ [''], ++ ]; ++ } ++ ++ public function testCreatingPerforceWithCommandExecutingPortThrows(): void ++ { ++ self::expectException(SecurityException::class); ++ self::expectExceptionMessage('Invalid Perforce port (rsh:touch /tmp/pwned)'); ++ ++ new Perforce($this->repoConfig, 'rsh:touch /tmp/pwned', self::TEST_PATH, $this->processExecutor, false, $this->io); ++ } ++ + /** + * Test if "p4" command is missing. + * diff -Nru composer-2.8.8/debian/patches/0027-Modernize-PHPUnit-syntax.patch composer-2.8.8/debian/patches/0027-Modernize-PHPUnit-syntax.patch --- composer-2.8.8/debian/patches/0027-Modernize-PHPUnit-syntax.patch 1970-01-01 01:00:00.000000000 +0100 +++ composer-2.8.8/debian/patches/0027-Modernize-PHPUnit-syntax.patch 2026-08-29 13:39:42.000000000 +0200 @@ -0,0 +1,103 @@ +From: =?utf-8?q?David_Pr=C3=A9vot?= <[email protected]> +Date: Fri, 29 Aug 2025 08:31:05 +0200 +Subject: Modernize PHPUnit syntax + +--- + tests/Composer/Test/IO/BaseIOTest.php | 5 ++--- + tests/Composer/Test/IO/ConsoleIOTest.php | 3 ++- + tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php | 4 +--- + tests/Composer/Test/Util/PerforceTest.php | 9 +++------ + 4 files changed, 8 insertions(+), 13 deletions(-) + +diff --git a/tests/Composer/Test/IO/BaseIOTest.php b/tests/Composer/Test/IO/BaseIOTest.php +index 7219837..050cfd1 100644 +--- a/tests/Composer/Test/IO/BaseIOTest.php ++++ b/tests/Composer/Test/IO/BaseIOTest.php +@@ -15,12 +15,11 @@ namespace Composer\Test\IO; + use Composer\Config; + use Composer\IO\BufferIO; + use Composer\Test\TestCase; ++use PHPUnit\Framework\Attributes\DataProvider; + + class BaseIOTest extends TestCase + { +- /** +- * @dataProvider provideValidGithubTokens +- */ ++ #[DataProvider('provideValidGithubTokens')] + public function testLoadConfigurationAcceptsValidGithubToken(string $token): void + { + $io = new BufferIO(); +diff --git a/tests/Composer/Test/IO/ConsoleIOTest.php b/tests/Composer/Test/IO/ConsoleIOTest.php +index cf38bf4..b758635 100644 +--- a/tests/Composer/Test/IO/ConsoleIOTest.php ++++ b/tests/Composer/Test/IO/ConsoleIOTest.php +@@ -16,6 +16,7 @@ use Composer\IO\ConsoleIO; + use Composer\Pcre\Preg; + use Composer\Test\TestCase; + use Symfony\Component\Console\Output\OutputInterface; ++use PHPUnit\Framework\Attributes\DataProvider; + + class ConsoleIOTest extends TestCase + { +@@ -298,10 +299,10 @@ class ConsoleIOTest extends TestCase + } + + /** +- * @dataProvider sanitizeProvider + * @param string|string[] $input + * @param string|string[] $expected + */ ++ #[DataProvider('sanitizeProvider')] + public function testSanitize($input, bool $allowNewlines, $expected): void + { + self::assertSame($expected, ConsoleIO::sanitize($input, $allowNewlines)); +diff --git a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +index 995ad89..04baa44 100644 +--- a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php ++++ b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +@@ -633,9 +633,7 @@ class ValidatingArrayLoaderTest extends TestCase + $this->expectNotToPerformAssertions(); + } + +- /** +- * @dataProvider provideMaliciousPackages +- */ ++ #[DataProvider('provideMaliciousPackages')] + public function testValidatePackageRejectsMaliciousMetadata(PackageInterface $package, string $expectedMessage): void + { + $this->expectException(SecurityException::class); +diff --git a/tests/Composer/Test/Util/PerforceTest.php b/tests/Composer/Test/Util/PerforceTest.php +index 8978ce0..8960767 100644 +--- a/tests/Composer/Test/Util/PerforceTest.php ++++ b/tests/Composer/Test/Util/PerforceTest.php +@@ -18,6 +18,7 @@ use Composer\Test\Mock\ProcessExecutorMock; + use Composer\Util\Perforce; + use Composer\Test\TestCase; + use Composer\Util\ProcessExecutor; ++use PHPUnit\Framework\Attributes\DataProvider; + + /** + * @author Matt Whittom <[email protected]> +@@ -626,9 +627,7 @@ class PerforceTest extends TestCase + self::assertFalse(Perforce::checkServerExists('rsh:touch /tmp/pwned', $this->processExecutor)); + } + +- /** +- * @dataProvider provideValidPorts +- */ ++ #[DataProvider('provideValidPorts')] + public function testIsValidPortAcceptsNetworkEndpoints(string $port): void + { + self::assertTrue(Perforce::isValidPort($port)); +@@ -653,9 +652,7 @@ class PerforceTest extends TestCase + ]; + } + +- /** +- * @dataProvider provideInvalidPorts +- */ ++ #[DataProvider('provideInvalidPorts')] + public function testIsValidPortRejectsNonNetworkEndpoints(string $port): void + { + self::assertFalse(Perforce::isValidPort($port)); diff -Nru composer-2.8.8/debian/patches/series composer-2.8.8/debian/patches/series --- composer-2.8.8/debian/patches/series 2026-05-14 10:37:03.000000000 +0200 +++ composer-2.8.8/debian/patches/series 2026-08-29 13:39:42.000000000 +0200 @@ -19,4 +19,9 @@ 0019-Merge-commit-from-fork.patch 0020-ProcessExecutor-mask-GitHub-fine-grained-access-toke.patch 0021-Fix-regexp-to-support-new-GitHub-installation-tokens.patch -0022-Modernize-PHPUnit-syntax.patch +0022-Merge-commit-from-fork.patch +0023-Relax-token-validation-on-input-and-hide-more-things.patch +0024-Merge-commit-from-fork.patch +0025-Merge-commit-from-fork.patch +0026-Merge-commit-from-fork.patch +0027-Modernize-PHPUnit-syntax.patch
signature.asc
Description: PGP signature

