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

Attachment: signature.asc
Description: PGP signature

Reply via email to