From 1a8cdb80c2e1503d6b40779f0dc179780be1e6c5 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 11 Sep 2026 22:53:18 +0200 Subject: [PATCH] Upstream composer/satis state of the touched files before 5356666b65 --- src/PackageSelection/PackageSelection.php | 1001 +++++++++++ .../PackageSelection/PackageSelectionTest.php | 1531 +++++++++++++++++ 2 files changed, 2532 insertions(+) create mode 100644 src/PackageSelection/PackageSelection.php create mode 100644 tests/PackageSelection/PackageSelectionTest.php diff --git a/src/PackageSelection/PackageSelection.php b/src/PackageSelection/PackageSelection.php new file mode 100644 index 0000000..6cf53dc --- /dev/null +++ b/src/PackageSelection/PackageSelection.php @@ -0,0 +1,1001 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Satis\PackageSelection; + +use Composer\Composer; +use Composer\DependencyResolver\Pool; +use Composer\Json\JsonFile; +use Composer\Package\AliasPackage; +use Composer\Package\BasePackage; +use Composer\Package\CompletePackage; +use Composer\Package\Link; +use Composer\Package\Loader\ArrayLoader; +use Composer\Package\PackageInterface; +use Composer\Package\Version\VersionSelector; +use Composer\PartialComposer; +use Composer\Repository\ArrayRepository; +use Composer\Repository\ArtifactRepository; +use Composer\Repository\ComposerRepository; +use Composer\Repository\ConfigurableRepositoryInterface; +use Composer\Repository\PackageRepository; +use Composer\Repository\PathRepository; +use Composer\Repository\PlatformRepository; +use Composer\Repository\RepositoryInterface; +use Composer\Repository\RepositorySet; +use Composer\Semver\Constraint\MatchAllConstraint; +use Composer\Semver\VersionParser; +use Composer\Util\Filesystem; +use Symfony\Component\Console\Output\OutputInterface; + +class PackageSelection +{ + /** The output Interface. */ + protected OutputInterface $output; + + /** Skips Exceptions if true. */ + protected bool $skipErrors; + + /** @var string packages.json file name. */ + private $filename; + + /** @var mixed Array of additional repositories for dependencies */ + private $depRepositories; + + /** Selects All Packages if true. */ + private bool $requireAll; + + /** Add required dependencies if true. */ + private bool $requireDependencies; + + /** required dev-dependencies if true. */ + private bool $requireDevDependencies; + + /** do not build packages only dependencies */ + private bool $onlyDependencies; + + /** only resolve best candidates in dependencies */ + private bool $onlyBestCandidates; + + /** Filter dependencies if true. */ + private bool $requireDependencyFilter; + + /** + * Minimum stability accepted for Packages in the list. + * + * @var key-of + */ + private string $minimumStability; + + /** @var string[] Minimum stability accepted by Package. */ + private array $minimumStabilityPerPackage; + + /** @var string[] The active package filter to merge. */ + private array $packagesFilter = []; + + /** @var string[]|null The active repository filter to merge. */ + private ?array $repositoriesFilter = null; + + /** @var mixed Repositories mentioned in the satis config */ + private $repositories; + + /** Apply the filter also for resolving dependencies. */ + private bool $repositoryFilterDep; + + /** @var PackageInterface[] The selected packages from config */ + private array $selected = []; + + /** @var string[] A list of packages marked as abandoned */ + private array $abandoned = []; + + /** @var string[] A list of blacklisted package/constraints. */ + private array $blacklist = []; + + /** @var string[]|null A list of package types. If set only packages with one of these types will be selected */ + private ?array $includeTypes; + + /** @var string[] A list of package types that will not be selected */ + private array $excludeTypes = []; + + /** @var mixed Patterns from strip-hosts. */ + private $stripHosts = false; + + /** The prefix of the distURLs when using archive. */ + private ?string $archiveEndpoint = null; + + /** The homepage - needed to get the relative paths of the providers */ + private ?string $homepage = null; + + /** + * @param array $config + */ + public function __construct(OutputInterface $output, string $outputDir, array $config, bool $skipErrors) + { + $this->output = $output; + $this->skipErrors = $skipErrors; + $this->filename = $outputDir . '/packages.json'; + $this->repositories = $config['repositories'] ?? []; + $this->fetchOptions($config); + } + + /** + * @param string[]|null $repositoriesFilter + */ + public function setRepositoriesFilter(?array $repositoriesFilter, bool $forDependencies = false): void + { + $this->repositoriesFilter = [] !== $repositoriesFilter ? $repositoriesFilter : null; + $this->repositoryFilterDep = $forDependencies; + } + + public function hasRepositoriesFilter(): bool + { + return null !== $this->repositoriesFilter; + } + + public function hasBlacklist(): bool + { + return count($this->blacklist) > 0; + } + + public function hasTypeFilter(): bool + { + return null !== $this->includeTypes || count($this->excludeTypes) > 0; + } + + /** + * @param string[] $packagesFilter + */ + public function setPackagesFilter(array $packagesFilter = []): void + { + $this->packagesFilter = $packagesFilter; + } + + public function hasFilterForPackages(): bool + { + return count($this->packagesFilter) > 0; + } + + /** + * @throws \InvalidArgumentException + * @throws \Exception + * + * @return PackageInterface[] + */ + public function select(PartialComposer $composer, bool $verbose): array + { + // run over all packages and store matching ones + $this->output->writeln('Scanning packages'); + + $repos = $initialRepos = $composer->getRepositoryManager()->getRepositories(); + + $stabilityFlags = array_map(function ($value) { + return BasePackage::STABILITIES[$value]; + }, $this->minimumStabilityPerPackage); + + if ($this->hasRepositoriesFilter()) { + $repos = $this->filterRepositories($repos); + + if (0 === count($repos)) { + throw new \InvalidArgumentException(sprintf('Specified repository URL(s) "%s" do not exist.', implode('", "', $this->repositoriesFilter ?? []))); + } + } else { + // Only use repos explicitly activated in satis config if no further filter given + $repos = []; + // Todo: Use a filter function instead + foreach ($initialRepos as $repo) { + if ($repo instanceof ConfigurableRepositoryInterface) { + $config = $repo->getRepoConfig(); + foreach ($this->repositories as $satisRepo) { + // TODO configurable repo types without URL attribute + // This is madness and should be an empty() but phpstan-strict-rules does not like empty() + if ( + !isset($config['url']) + || !is_string($config['url']) + || '' === $config['url'] + || !isset($satisRepo['url']) + || !is_string($satisRepo['url']) + || '' === $satisRepo['url'] + ) { + continue; + } + // Treat any combination of missing or present trailing slash as equal + if (rtrim($config['url'], '/') === rtrim($satisRepo['url'], '/')) { + $repos[] = $repo; + } + } + } else { + if ($repo instanceof ArrayRepository) { + $repos[] = $repo; + } + } + } + } + + if ($this->hasFilterForPackages()) { + $repos = $this->filterPackages($repos); + + if (0 === count($repos)) { + throw new \InvalidArgumentException(sprintf('Could not find any repositories config with "name" matching your package(s) filter: %s', implode(', ', $this->packagesFilter))); + } + } + + $repositorySet = new RepositorySet($this->minimumStability, $stabilityFlags); + $this->addRepositories($repositorySet, $repos); + + // determine the required packages + $rootLinks = $this->requireAll ? $this->getAllLinks($repos, $this->minimumStability, $verbose) : $this->getFilteredLinks($composer); + + // select the required packages and determine dependencies + $depsLinks = $this->selectLinks($repositorySet, $rootLinks, true, $verbose); + + if ($this->requireDependencies || $this->requireDevDependencies) { + $repositorySet = new RepositorySet($this->minimumStability, $stabilityFlags); + $this->addRepositories($repositorySet, $repos); + // dependencies of required packages might have changed and be part of filtered repos + if ($this->hasRepositoriesFilter() && true !== $this->repositoryFilterDep) { + $this->addRepositories( + $repositorySet, + \array_udiff( + $initialRepos, + $repos, + fn ($a, $b) => (method_exists($a, 'getRepoName') ? $a->getRepoName() : '') <=> (method_exists($b, 'getRepoName') ? $b->getRepoName() : '') + ) + ); + } + + // additional repositories for dependencies + if (!$this->hasRepositoriesFilter() || true !== $this->repositoryFilterDep) { + $this->addRepositories($repositorySet, $this->getDepRepos($composer)); + } + + // select dependencies + $this->selectLinks($repositorySet, $depsLinks, false, $verbose); + } + + $this->setSelectedAsAbandoned(); + + $this->pruneBlacklisted($verbose); + $this->pruneByType($verbose); + + ksort($this->selected, SORT_STRING); + + return $this->selected; + } + + /** + * @return PackageInterface[] + */ + public function clean(): array + { + $this->applyStripHosts(); + + return $this->selected; + } + + /** + * @return PackageInterface[] + */ + public function load(): array + { + $packages = []; + $rootJsonFile = new JsonFile($this->filename); + $dirname = dirname($this->filename); + + if (!$rootJsonFile->exists()) { + return $packages; + } + + $loader = new ArrayLoader(); + $rootConfig = $rootJsonFile->read(); + $includes = []; + + if (isset($rootConfig['includes']) && is_array($rootConfig['includes'])) { + $includes = $rootConfig['includes']; + } + + if (isset($rootConfig['providers']) && is_array($rootConfig['providers']) && isset($rootConfig['providers-url'])) { + $baseUrl = is_string($this->homepage) ? parse_url(rtrim($this->homepage, '/'), PHP_URL_PATH) . '/' : ''; + $baseUrlLength = strlen($baseUrl); + + foreach ($rootConfig['providers'] as $package => $provider) { + $file = str_replace(['%package%', '%hash%'], [$package, $provider['sha256']], (string) $rootConfig['providers-url']); + + if (strlen($baseUrl) > 0 && substr($file, 0, $baseUrlLength) === $baseUrl) { + $file = substr($file, $baseUrlLength); + } + + $includes[$file] = $provider; + } + } + + foreach (array_keys($includes) as $file) { + $includedJsonFile = new JsonFile($dirname . '/' . $file); + + if (!$includedJsonFile->exists()) { + $this->output->writeln(sprintf( + 'File \'%s\' does not exist, defined in "includes" in \'%s\'', + $includedJsonFile->getPath(), + $rootJsonFile->getPath() + )); + + continue; + } + + $includedConfig = $includedJsonFile->read(); + + if (!isset($includedConfig['packages']) || !is_array($includedConfig['packages'])) { + continue; + } + + $includedPackages = $includedConfig['packages']; + + foreach ($includedPackages as $name => $versions) { + if (!is_array($versions)) { + continue; + } + + foreach ($versions as $package) { + if (!is_array($package)) { + continue; + } + + if (isset($package['name']) && in_array($package['name'], $this->packagesFilter, true)) { + continue; + } + + $package = $loader->load($package); + + if ($package instanceof AliasPackage) { + $package = $package->getAliasOf(); + } + + $packages[$package->getUniqueName()] = $package; + } + } + } + + return $packages; + } + + /** + * @param array $config + */ + private function fetchOptions(array $config): void + { + $this->depRepositories = $config['repositories-dep'] ?? []; + + $this->requireAll = isset($config['require-all']) && true === $config['require-all']; + $this->requireDependencies = isset($config['require-dependencies']) && true === $config['require-dependencies']; + $this->requireDevDependencies = isset($config['require-dev-dependencies']) && true === $config['require-dev-dependencies']; + $this->onlyDependencies = isset($config['only-dependencies']) && true === $config['only-dependencies']; + $this->onlyBestCandidates = isset($config['only-best-candidates']) && true === $config['only-best-candidates']; + $this->requireDependencyFilter = (bool) ($config['require-dependency-filter'] ?? true); + + if (!$this->requireAll && !isset($config['require'])) { + $this->output->writeln('No explicit requires defined, enabling require-all'); + $this->requireAll = true; + } + + $this->minimumStability = $config['minimum-stability'] ?? 'dev'; + $this->minimumStabilityPerPackage = $config['minimum-stability-per-package'] ?? []; + $this->abandoned = $config['abandoned'] ?? []; + $this->blacklist = $config['blacklist'] ?? []; + $this->includeTypes = $config['include-types'] ?? null; + $this->excludeTypes = $config['exclude-types'] ?? []; + + $this->stripHosts = $this->createStripHostsPatterns($config['strip-hosts'] ?? false); + $this->archiveEndpoint = isset($config['archive']['directory']) ? ($config['archive']['prefix-url'] ?? $config['homepage']) . '/' : null; + + $this->homepage = $config['homepage'] ?? null; + } + + /** + * @param string[]|false $stripHostsConfig + * + * @return array|false + */ + private function createStripHostsPatterns($stripHostsConfig) + { + if (!is_array($stripHostsConfig)) { + return $stripHostsConfig; + } + + $patterns = []; + + foreach ($stripHostsConfig as $entry) { + if (0 === strlen($entry)) { + continue; + } + + if ('/private' === $entry || '/local' === $entry) { + $patterns[] = [$entry]; + continue; + } elseif (false !== strpos($entry, ':')) { + $type = 'ipv6'; + if (!defined('AF_INET6')) { + $this->output->writeln('Unable to use IPv6.'); + continue; + } + } elseif (0 === preg_match('#[^/.\\d]#', $entry)) { + $type = 'ipv4'; + } else { + $type = 'name'; + $host = '#^(?:.+\.)?' . preg_quote($entry, '#') . '$#ui'; + $patterns[] = [$type, $host]; + continue; + } + + $parts = explode('/', $entry, 2); + $host = @inet_pton($parts[0]); + $mask = $parts[1] ?? null; + + if (false === $host) { + $this->output->writeln(sprintf('Host syntactically invalid "%s"', $entry)); + continue; + } + + // casting to int and back to string to check if mask is a pure number + if (null !== $mask && (string) (int) $mask !== $mask) { + $this->output->writeln(sprintf('Invalid subnet mask "%s"', $entry)); + continue; + } + + $host = unpack('N*', $host); + + if (null === $mask) { + $mask = 'ipv4' === $type ? 32 : 128; + } else { + $mask = (int) $mask; + + if ($mask < 0 || ('ipv4' === $type && $mask > 32) || ('ipv6' === $type && $mask > 128)) { + continue; + } + } + + $patterns[] = [$type, $host, $mask]; + } + + return $patterns; + } + + private function applyStripHosts(): void + { + if (false === $this->stripHosts) { + return; + } + + /** @var CompletePackage $package */ + foreach ($this->selected as $uniqueName => $package) { + $sources = []; + + if (is_string($package->getSourceType())) { + $sources[] = 'source'; + } + + if (is_string($package->getDistType())) { + $sources[] = 'dist'; + } + + foreach ($sources as $index => $type) { + $url = (string) ('source' === $type ? $package->getSourceUrl() : $package->getDistUrl()); + + // skip distURL applied by ArchiveBuilder + if ('dist' === $type && null !== $this->archiveEndpoint + && substr($url, 0, strlen($this->archiveEndpoint)) === $this->archiveEndpoint + ) { + continue; + } + + if ($this->matchStripHostsPatterns($url)) { + if ('dist' === $type) { + // if the type is not set, ArrayDumper ignores the other properties + $package->setDistType(null); + } else { + $package->setSourceType(null); + } + + unset($sources[$index]); + + if (0 === count($sources)) { + $this->output->writeln(sprintf('%s has no source left after applying the strip-hosts filters and will be removed', $package->getUniqueName())); + + unset($this->selected[$uniqueName]); + } + } + } + } + } + + private function matchStripHostsPatterns(string $url): bool + { + if (Filesystem::isLocalPath($url)) { + return true; + } + + if (!is_array($this->stripHosts)) { + return false; + } + + $sshRegex = '#^[^@:\/]+@([^\/:]+)#ui'; + if (1 === preg_match($sshRegex, $url, $matches)) { + $url = $matches[1]; + } else { + $url = trim((string) parse_url($url, PHP_URL_HOST), '[]'); + } + + if (false !== filter_var($url, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $urltype = 'ipv4'; + } elseif (false !== filter_var($url, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $urltype = 'ipv6'; + } else { + $urltype = 'name'; + } + + $urlunpack = null; + if ('ipv4' === $urltype || 'ipv6' === $urltype) { + $urlunpack = (array) unpack('N*', (string) @inet_pton($url)); + } + + foreach ($this->stripHosts as $pattern) { + @list($type, $host, $mask) = $pattern; + + if ('/local' === $type) { + if (('name' === $urltype && 'localhost' === strtolower($url)) || ( + ('ipv4' === $urltype || 'ipv6' === $urltype) + && false === filter_var($url, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) + )) { + return true; + } + } elseif ('/private' === $type) { + if (('ipv4' === $urltype || 'ipv6' === $urltype) + && false === filter_var($url, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) + ) { + return true; + } + } elseif ('ipv4' === $type || 'ipv6' === $type) { + if ($urltype === $type && $this->matchAddr($urlunpack, $host, $mask)) { + return true; + } + } elseif ('name' === $type) { + if ('name' === $urltype && 1 === preg_match($host, $url)) { + return true; + } + } + } + + return false; + } + + /** + * Test if two addresses have the same prefix + * + * @param int[] $addr1 Chunked addr + * @param int[] $addr2 Chunked addr + * @param int $len Length of the test + * @param int $chunklen Length of each chunk + */ + private function matchAddr($addr1, $addr2, $len = 0, $chunklen = 32): bool + { + for (; $len > 0; $len -= $chunklen, next($addr1), next($addr2)) { + $shift = $len >= $chunklen ? 0 : $chunklen - $len; + + if ((current($addr1) >> $shift) !== (current($addr2) >> $shift)) { + return false; + } + } + + return true; + } + + /** + * @param array $repositories + * + * @throws \Exception + */ + private function addRepositories(RepositorySet $repositorySet, array $repositories): void + { + foreach ($repositories as $repository) { + try { + if ($repository instanceof RepositoryInterface) { + $repositorySet->addRepository($repository); + } + } catch (\Exception $exception) { + if (!$this->skipErrors) { + throw $exception; + } + + $this->output->writeln(sprintf("Skipping Exception '%s'.", $exception->getMessage())); + } + } + } + + private function setSelectedAsAbandoned(): void + { + /** @var CompletePackage $package */ + foreach ($this->selected as $name => $package) { + if (array_key_exists($package->getName(), $this->abandoned)) { + $package->setAbandoned($this->abandoned[$package->getName()]); + } + } + } + + /** + * Removes selected packages which are blacklisted in configuration. + * + * @return PackageInterface[] + */ + private function pruneBlacklisted(bool $verbose): array + { + $blacklisted = []; + if ($this->hasBlacklist()) { + $parser = new VersionParser(); + // Pool::match() only inspects the candidate package itself, so an + // empty pool suffices; loading all packages would fail on composer + // repositories using providers (which can not list their packages) + $pool = new Pool(); + /** @var BasePackage $package */ + foreach ($this->selected as $selectedKey => $package) { + foreach ($this->blacklist as $blacklistName => $blacklistConstraint) { + $constraint = $parser->parseConstraints($blacklistConstraint); + if ($pool->match($package, $blacklistName, $constraint)) { + if ($verbose) { + $this->output->writeln('Blacklisted ' . $package->getPrettyName() . ' (' . $package->getPrettyVersion() . ')'); + } + $blacklisted[$selectedKey] = $package; + unset($this->selected[$selectedKey]); + } + } + } + } + + return $blacklisted; + } + + /** + * Removes packages with types that don't match the configuration + * + * @return PackageInterface[] + */ + private function pruneByType(bool $verbose): array + { + $excluded = []; + if ($this->hasTypeFilter()) { + foreach ($this->selected as $selectedKey => $package) { + if (null !== $this->includeTypes && !in_array($package->getType(), $this->includeTypes, true)) { + if ($verbose) { + $this->output->writeln( + 'Excluded ' . $package->getPrettyName() + . ' (' . $package->getPrettyVersion() . ') because ' + . $package->getType() . ' was not in the array of types to include.' + ); + } + $excluded[$selectedKey] = $package; + unset($this->selected[$selectedKey]); + } elseif (in_array($package->getType(), $this->excludeTypes, true)) { + if ($verbose) { + $this->output->writeln( + 'Excluded ' . $package->getPrettyName() + . ' (' . $package->getPrettyVersion() . ') because ' + . $package->getType() . ' was in the array of types to exclude.' + ); + } + $excluded[$selectedKey] = $package; + unset($this->selected[$selectedKey]); + } + } + } + + return $excluded; + } + + /** + * Gets a list of filtered Links. + * + * @return Link[] + */ + private function getFilteredLinks(PartialComposer $composer): array + { + $links = array_values($composer->getPackage()->getRequires()); + + if (!$this->hasFilterForPackages()) { + return $links; + } + + $packagesFilter = $this->packagesFilter; + $links = array_filter( + $links, + function (Link $link) use ($packagesFilter) { + return in_array($link->getTarget(), $packagesFilter, true); + } + ); + + return array_values($links); + } + + /** + * @param array $repositories + * + * @return Link[]|PackageInterface[] + */ + private function getAllLinks(array $repositories, string $minimumStability, bool $verbose): array + { + $links = []; + + foreach ($repositories as $repository) { + if ($repository instanceof ComposerRepository) { + foreach ($repository->getPackageNames() as $name) { + $links[] = new Link('__root__', $name, new MatchAllConstraint(), 'requires', '*'); + } + continue; + } + + try { + if ($repository instanceof RepositoryInterface) { + $packages = $this->getPackages($repository); + } else { + continue; + } + } catch (\Exception $exception) { + if (!$this->skipErrors) { + throw $exception; + } + + $this->output->writeln(sprintf("Skipping Exception '%s'.", $exception->getMessage())); + continue; + } + + foreach ($packages as $package) { + if ($package instanceof AliasPackage) { + continue; + } + + if (BasePackage::STABILITIES[$package->getStability()] > BasePackage::STABILITIES[$minimumStability]) { + if ($verbose) { + $this->output->writeln('Skipped ' . $package->getPrettyName() . ' (' . $package->getStability() . ')'); + } + + continue; + } + + $links[] = $package; + } + } + + return $links; + } + + /** + * @param array $links + * + * @return array + */ + private function selectLinks(RepositorySet $repositorySet, array $links, bool $isRoot, bool $verbose): array + { + $depsLinks = $isRoot ? [] : $links; + $poolsByName = []; + + reset($links); + + while (null !== key($links)) { + $link = current($links); + $matches = []; + if (false !== $link && is_a($link, PackageInterface::class)) { + $matches = [$link]; + } elseif (false !== $link && is_a($link, Link::class)) { + $name = $link->getTarget(); + if (!$isRoot && $this->onlyBestCandidates) { + $selector = new VersionSelector($repositorySet); + $match = $selector->findBestCandidate($name, $link->getConstraint()->getPrettyString()); + $matches = false !== $match ? [$match] : []; + } elseif (PlatformRepository::isPlatformPackage($name)) { + } else { + // the pool only depends on the target name, so reuse it across + // links with different constraints + $poolsByName[$name] ??= $repositorySet->createPoolForPackage($name); + $matches = $poolsByName[$name]->whatProvides($name, $link->getConstraint()); + } + + if (0 === \count($matches)) { + $this->output->writeln('The ' . $name . ' ' . $link->getPrettyConstraint() . ' requirement did not match any package'); + } + } + + foreach ($matches as $package) { + // skip aliases + if ($package instanceof AliasPackage) { + $package = $package->getAliasOf(); + } + + $uniqueName = $package->getUniqueName(); + $prettyVersion = $package->getPrettyVersion(); + + if (!is_string($prettyVersion)) { + $this->output->writeln('Skipping ' . $package->getPrettyName() . ' (' . $prettyVersion . ') due to invalid version type'); + + continue; + } + + // Check if + character is present, only once according to Semver; + // otherwise metadata will stripped as usual + if (1 === substr_count($prettyVersion, '+')) { + // re-inject metadata because it has been stripped by the VersionParser + if (1 === preg_match('/.+(\+[0-9A-Za-z-]*)$/', $prettyVersion, $match)) { + $uniqueName .= $match[1]; + } + } + + // add matching package if not yet selected + if (!isset($this->selected[$uniqueName])) { + if (false === $isRoot || false === $this->onlyDependencies) { + if ($verbose) { + $this->output->writeln('Selected ' . $package->getPrettyName() . ' (' . $prettyVersion . ')'); + } + $this->selected[$uniqueName] = $package; + } + + $required = $this->getRequired($package, $isRoot); + // append non-platform dependencies + foreach ($required as $dependencyLink) { + $target = $dependencyLink->getTarget(); + if (!PlatformRepository::isPlatformPackage($target)) { + $linkId = $target . ' ' . $dependencyLink->getConstraint(); + // prevent loading multiple times the same link + if (!isset($depsLinks[$linkId])) { + if (false === $isRoot) { + $links[] = $dependencyLink; + } + $depsLinks[$linkId] = $dependencyLink; + } + } + } + } + } + + next($links); + } + + return $depsLinks; + } + + /** + * @return RepositoryInterface[] + */ + private function getDepRepos(PartialComposer $composer): array + { + $repositories = []; + + if (\is_array($this->depRepositories)) { + $repositoryManager = $composer->getRepositoryManager(); + + foreach ($this->depRepositories as $index => $config) { + if (!isset($config['type'])) { + continue; + } + $name = \is_int($index) && isset($config['url']) ? $config['url'] : (string) $index; + $repositories[$index] = $repositoryManager->createRepository($config['type'], $config, $name); + } + } + + return $repositories; + } + + /** + * @return PackageInterface[] + */ + private function getPackages(RepositoryInterface $repo): array + { + $packages = []; + + if (!$this->hasFilterForPackages()) { + return $repo->getPackages(); + } + + foreach ($this->packagesFilter as $filter) { + $packages += $repo->findPackages($filter); + } + + return $packages; + } + + /** + * @return Link[] + */ + private function getRequired(PackageInterface $package, bool $isRoot): array + { + $required = []; + + if ($this->requireDependencies) { + $required = $package->getRequires(); + } + + if (($isRoot || !$this->requireDependencyFilter) && $this->requireDevDependencies) { + $required = array_merge($required, $package->getDevRequires()); + } + + return $required; + } + + /** + * @param RepositoryInterface[]|ConfigurableRepositoryInterface[] $repositories + * + * @return RepositoryInterface[]|ConfigurableRepositoryInterface[] + */ + private function filterRepositories(array $repositories): array + { + return array_filter( + $repositories, + function ($repository) { + if ($repository instanceof ConfigurableRepositoryInterface) { + $config = $repository->getRepoConfig(); + if (!isset($config['url'])) { + return false; + } + + return in_array($config['url'], $this->repositoriesFilter ?? [], true); + } elseif ($repository instanceof ArrayRepository) { + $packages = $repository->getPackages(); + foreach ($packages as $package) { + if (in_array($package->getSourceUrl(), $this->repositoriesFilter ?? [], true)) { + return true; + } + } + + return false; + } + + return false; + } + ); + } + + /** + * @param RepositoryInterface[]|ConfigurableRepositoryInterface[] $repositories + * + * @return RepositoryInterface[]|ConfigurableRepositoryInterface[] + */ + private function filterPackages(array $repositories): array + { + $packages = $this->packagesFilter; + + return array_filter( + $repositories, + static function ($repository) use ($packages) { + if ($repository instanceof ConfigurableRepositoryInterface) { + $config = $repository->getRepoConfig(); + + // We need name to be set on repo config as it would otherwise be too slow on remote repos (VCS, ..) + if (isset($config['name']) && in_array($config['name'], $packages, true)) { + return true; + } + } + + // "artifact", "path" and "package" repositories resolve their whole + // package list from the local filesystem or from the Satis config + // itself, so matching the names they really provide costs no network + // round trip and needs no "name" in the repository config. + if ($repository instanceof ArtifactRepository + || $repository instanceof PathRepository + || $repository instanceof PackageRepository + ) { + foreach ($repository->getPackages() as $package) { + if (in_array($package->getName(), $packages, true)) { + return true; + } + } + } + + return false; + } + ); + } +} diff --git a/tests/PackageSelection/PackageSelectionTest.php b/tests/PackageSelection/PackageSelectionTest.php new file mode 100644 index 0000000..7676f75 --- /dev/null +++ b/tests/PackageSelection/PackageSelectionTest.php @@ -0,0 +1,1531 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Satis\PackageSelection; + +use Composer\Config; +use Composer\Factory; +use Composer\IO\NullIO; +use Composer\Package\CompletePackage; +use Composer\Package\Link; +use Composer\Package\Package; +use Composer\Repository\ArrayRepository; +use Composer\Repository\ConfigurableRepositoryInterface; +use Composer\Repository\PackageRepository; +use Composer\Repository\RepositorySet; +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\MatchAllConstraint; +use Composer\Util\Filesystem; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\NullOutput; + +/** + * @author James Hautot + */ +class PackageSelectionTest extends TestCase +{ + private const REPOSITORY_TYPE_ARTIFACT = 'artifact'; + private const REPOSITORY_TYPE_PATH = 'path'; + private const REPOSITORY_TYPE_PACKAGE = 'package'; + + /** Version declared by the local repository fixtures. */ + private const FIXTURE_VERSION = '1.0.0'; + + /** Same version as {@see self::FIXTURE_VERSION}, as Composer normalizes it. */ + private const FIXTURE_NORMALIZED_VERSION = '1.0.0.0'; + + /** + * @return array + */ + public static function dataGetPackages(): array + { + $emptyRepo = new ArrayRepository(); + $vendorRepo = new ArrayRepository(); + $vendorRepo2 = new ArrayRepository(); + + $package = new Package('vendor/name', '1.0.0.0', '1.0'); + $package2 = new Package('vendor2/name', '1.0.0.0', '1.0'); + $package3 = new Package('vendor2/name2', '1.0.0.0', '1.0'); + $vendorRepo->addPackage($package); + $vendorRepo2->addPackage($package2); + $vendorRepo2->addPackage($package3); + + $data = []; + + $data['empty repository'] = [ + [], + [], + $emptyRepo, + ]; + + $data['empty repository with filter'] = [ + [], + ['vendor/name'], + $emptyRepo, + ]; + + $data['repository with one package'] = [ + [$package], + [], + $vendorRepo, + ]; + + $data['repository with one package and filter'] = [ + [], + ['othervendor/othername'], + $vendorRepo, + ]; + + $data['repository with two packages'] = [ + [$package2, $package3], + [], + $vendorRepo2, + ]; + + $data['repository with two packages and filter'] = [ + [$package2], + ['vendor2/name'], + $vendorRepo2, + ]; + + return $data; + } + + /** + * @param string[] $expected + * @param string[] $filter + */ + #[DataProvider('dataGetPackages')] + public function testGetPackages(array $expected, array $filter, ArrayRepository $repository): void + { + $builder = new PackageSelection(new NullOutput(), 'build', [], false); + if (count($filter) > 0) { + $builder->setPackagesFilter($filter); + } + + $reflection = new \ReflectionClass(get_class($builder)); + $method = $reflection->getMethod('getPackages'); + + self::assertSame($expected, $method->invokeArgs($builder, [$repository])); + } + + /** + * @return array + */ + public static function dataGetRequired(): array + { + $package = new Package('vendor/name', '1.0.0.0', '1.0'); + $link = new Link('test', 'name', new MatchAllConstraint()); + $devLink = new Link('devTest', 'name', new MatchAllConstraint()); + $package->setRequires([$link->getTarget() => $link]); + $package->setDevRequires([$devLink->getTarget() => $devLink]); + + $data = []; + + $data['both require false'] = [ + [], + $package, + false, + false, + ]; + + $data['require true'] = [ + [$link->getTarget() => $link], + $package, + true, + false, + ]; + + $data['requireDev true'] = [ + [$devLink->getTarget() => $devLink], + $package, + false, + true, + ]; + + $data['both require true'] = [ + [$link->getTarget() => $link, $devLink->getTarget() => $devLink], + $package, + true, + true, + ]; + + return $data; + } + + /** + * @param string[] $expected + */ + #[DataProvider('dataGetRequired')] + public function testGetRequired(array $expected, Package $package, bool $requireDependencies, bool $requireDevDependencies): void + { + $builder = new PackageSelection(new NullOutput(), 'build', [], false); + + $reflection = new \ReflectionClass(get_class($builder)); + $method = $reflection->getMethod('getRequired'); + + $property = $reflection->getProperty('requireDependencies'); + $property->setValue($builder, $requireDependencies); + + $property = $reflection->getProperty('requireDevDependencies'); + $property->setValue($builder, $requireDevDependencies); + + self::assertSame($expected, $method->invokeArgs($builder, [$package, true])); + } + + /** + * @return array + */ + public static function dataSetSelectedAsAbandoned(): array + { + $package = new CompletePackage('vendor/name', '1.0.0.0', '1.0'); + $packageAbandoned1 = new CompletePackage('vendor/name', '1.0.0.0', '1.0'); + $packageAbandoned1->setAbandoned(true); + $packageAbandoned2 = new CompletePackage('vendor/name', '1.0.0.0', '1.0'); + $packageAbandoned2->setAbandoned('othervendor/othername'); + + $data = []; + + $data['Nothing Abandoned'] = [ + [$package->getUniqueName() => $package], + [], + ]; + + $data['Package Abandoned without Replacement'] = [ + [$package->getUniqueName() => $packageAbandoned1], + ['vendor/name' => true], + ]; + + $data['Package Abandoned with Replacement'] = [ + [$package->getUniqueName() => $packageAbandoned2], + ['vendor/name' => 'othervendor/othername'], + ]; + + return $data; + } + + /** + * @param string[] $expected + * @param array $config + */ + #[DataProvider('dataSetSelectedAsAbandoned')] + public function testSetSelectedAsAbandoned(array $expected, array $config): void + { + $package = new CompletePackage('vendor/name', '1.0.0.0', '1.0'); + + $builder = new PackageSelection(new NullOutput(), 'build', [ + 'abandoned' => $config, + ], false); + + $reflection = new \ReflectionClass(get_class($builder)); + $method = $reflection->getMethod('setSelectedAsAbandoned'); + + $property = $reflection->getProperty('selected'); + $property->setValue($builder, [$package->getUniqueName() => $package]); + + $method->invokeArgs($builder, []); + + self::assertEquals($expected, $property->getValue($builder)); + } + + /** + * @return array + */ + public static function dataPruneBlacklisted(): array + { + $package0 = new Package('vendor/name', '1.0.0.0', '1.0'); + $package1 = new Package('vendor/name', '1.1.0.0', '1.1'); + $package2 = new Package('vendor/name', '1.2.0.0', '1.2'); + $packageOther = new Package('vendor/other', '1.0.0.0', '1.0'); + $packageReplacing = new Package('vendor/replacer', '1.0.0.0', '1.0'); + $packageReplacing->setReplaces([ + 'vendor/replaced' => new Link('vendor/replacer', 'vendor/replaced', new Constraint('==', '1.0.0.0'), Link::TYPE_REPLACE, '1.0'), + ]); + + $data = []; + $selected = [$package0, $package1, $package2, $packageOther]; + + $data['Blacklist Whole Project'] = [ + [$packageOther], + $selected, + ['vendor/name' => '*'], + ]; + + $data['Blacklist One Package'] = [ + [$package0, $package2, $packageOther], + $selected, + ['vendor/name' => '1.1'], + ]; + + $data['Blacklist Newer Packages'] = [ + [$package0, $package1, $packageOther], + $selected, + ['vendor/name' => '>1.1'], + ]; + + $data['Blacklist Replaced Package'] = [ + [$packageOther], + [$packageReplacing, $packageOther], + ['vendor/replaced' => '*'], + ]; + + return $data; + } + + /** + * @param string[] $expected + * @param string[] $selected + * @param array $config + */ + #[DataProvider('dataPruneBlacklisted')] + public function testPruneBlacklisted(array $expected, array $selected, array $config): void + { + $builder = new PackageSelection(new NullOutput(), 'build', [ + 'blacklist' => $config, + ], false); + $reflection = new \ReflectionClass(get_class($builder)); + + $property = $reflection->getProperty('selected'); + $property->setValue($builder, $selected); + + $method = $reflection->getMethod('pruneBlacklisted'); + $method->invokeArgs($builder, [false]); + + self::assertEquals(array_values($expected), array_values($property->getValue($builder))); + } + + /** + * @return array + */ + public static function dataSelect(): array + { + $packages = [ + 'alpha' => [ + 'name' => 'vendor/project-alpha', + 'version' => '1.2.3.0', + ], + 'alpha-dev' => [ + 'name' => 'vendor/project-alpha', + 'version' => '1.2.3.1-dev', + ], + 'beta' => [ + 'name' => 'vendor/project-beta', + 'version' => '1.2.3.0', + 'type' => 'library', + ], + 'beta-dev' => [ + 'name' => 'vendor/project-beta', + 'version' => '1.2.3.1-dev', + 'type' => 'library', + ], + 'gamma1' => [ + 'name' => 'vendor/project-gamma', + 'version' => '1.2.3.0', + 'type' => 'project', + ], + 'gamma2' => [ + 'name' => 'vendor/project-gamma', + 'version' => '2.3.4.0', + 'type' => 'project', + ], + 'gamma3' => [ + 'name' => 'vendor/project-gamma', + 'version' => '3.4.5.0', + 'type' => 'library', + ], + 'gamma4' => [ + 'name' => 'vendor/project-gamma', + 'version' => '4.5.6.0', + 'type' => 'library', + ], + 'delta' => [ + 'name' => 'vendor/project-delta', + 'version' => '1.2.3.0', + 'type' => 'project', + 'require' => [ + 'vendor/project-alpha' => '^1', + 'vendor/project-gamma' => '^1', + ], + ], + 'epsilon' => [ + 'name' => 'vendor/project-epsilon', + 'version' => '1.2.3.0', + 'type' => 'custom-type', + 'require' => [ + 'vendor/project-alpha' => '^1', + ], + 'require-dev' => [ + 'vendor/project-gamma' => '^4', + ], + ], + 'zeta' => [ + 'name' => 'vendor/project-zeta', + 'version' => '1.2.3.0', + 'type' => 'another-custom-type', + 'require' => [ + 'vendor/project-epsilon' => '^1', + ], + ], + 'eta' => [ + 'name' => 'vendor/project-eta', + 'version' => '1.2.3.0', + 'type' => 'another-custom-type', + 'require' => [ + 'vendor/project-gamma' => '>=1', + ], + ], + ]; + + $repo = []; + $repo['alpha'] = [ + 'type' => 'package', + 'url' => 'example.org/project-alpha', + 'package' => [ + $packages['alpha'], + $packages['alpha-dev'], + ], + ]; + $repo['beta'] = [ + 'type' => 'package', + 'url' => 'example.org/project-beta', + 'package' => [ + $packages['beta'], + $packages['beta-dev'], + ], + ]; + $repo['gamma'] = [ + 'type' => 'package', + 'url' => 'example.org/project-gamma', + 'package' => [ + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + ], + ]; + $repo['delta'] = [ + 'type' => 'package', + 'url' => 'example.org/project-delta', + 'package' => $packages['delta'], + ]; + $repo['epsilon'] = [ + 'type' => 'package', + 'url' => 'example.org/project-epsilon', + 'package' => [ + $packages['epsilon'], + ], + ]; + $repo['zeta'] = [ + 'type' => 'package', + 'url' => 'example.org/project-zeta', + 'package' => [ + $packages['zeta'], + ], + ]; + $repo['eta'] = [ + 'type' => 'package', + 'url' => 'example.org/project-eta', + 'package' => [ + $packages['eta'], + ], + ]; + + foreach ($packages as &$p) { + $p = $p['name'] . '-' . $p['version']; + } + + $data = []; + + $data['Require-all'] = [ + $packages, + [ + 'repositories' => array_values($repo), + ], + ]; + + $data['Require'] = [ + [ + $packages['alpha'], + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-alpha' => '>=1', + 'vendor/project-gamma' => '>=1', + ], + ], + ]; + + $data['Require dependencies'] = [ + [ + $packages['delta'], + $packages['alpha'], + $packages['gamma1'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-delta' => '>=1', + ], + 'require-dependencies' => true, + ], + ]; + + $data['Require dependencies and dev-dependencies'] = [ + [ + $packages['epsilon'], + $packages['alpha'], + $packages['gamma4'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-epsilon' => '>=1', + ], + 'require-dependencies' => true, + 'require-dev-dependencies' => true, + ], + ]; + + $data['Require only dependencies'] = [ + [ + $packages['alpha'], + $packages['epsilon'], + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-epsilon' => '*', + 'vendor/project-zeta' => '*', + 'vendor/project-eta' => '*', + ], + 'require-dependencies' => true, + 'require-dev-dependencies' => true, + 'only-dependencies' => true, + ], + ]; + + $data['Traverse dependencies but not dev-dependencies'] = [ + [ + $packages['zeta'], + $packages['epsilon'], + $packages['alpha'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-zeta' => '>=1', + ], + 'require-dependencies' => true, + 'require-dev-dependencies' => true, + ], + ]; + + $data['Reduce required dependencies'] = [ + [ + $packages['eta'], + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-eta' => '>=1', + ], + 'require-dependencies' => true, + ], + ]; + + $data['Load from repositories-dep'] = [ + [ + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + $packages['delta'], + $packages['alpha'], + ], + [ + 'minimum-stability' => 'stable', + 'repositories' => [ + $repo['gamma'], + $repo['delta'], + ], + 'repositories-dep' => array_values($repo), + 'require-dependencies' => true, + ], + ]; + + $data['Set minimum-stability for a specific package'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['beta'], + ], + [ + 'minimum-stability' => 'stable', + 'minimum-stability-per-package' => [ + 'vendor/project-alpha' => 'dev', + ], + 'repositories' => array_values($repo), + 'require' => [ + 'vendor/project-alpha' => '*', + 'vendor/project-beta' => '*', + ], + ], + ]; + + $data['Filter just libraries'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['beta'], + $packages['beta-dev'], + $packages['gamma3'], + $packages['gamma4'], + ], + [ + 'include-types' => [ + 'library', + ], + 'repositories' => array_values($repo), + ], + ]; + + $data['Filter just libraries and projects'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['beta'], + $packages['beta-dev'], + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + $packages['delta'], + ], + [ + 'include-types' => [ + 'library', + 'project', + ], + 'repositories' => array_values($repo), + ], + ]; + + $data['Filter exclude projects'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['beta'], + $packages['beta-dev'], + $packages['gamma3'], + $packages['gamma4'], + $packages['epsilon'], + $packages['zeta'], + $packages['eta'], + ], + [ + 'exclude-types' => [ + 'project', + ], + 'repositories' => array_values($repo), + ], + ]; + + $data['Filter exclude projects, custom'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['beta'], + $packages['beta-dev'], + $packages['gamma3'], + $packages['gamma4'], + $packages['zeta'], + $packages['eta'], + ], + [ + 'exclude-types' => [ + 'project', + 'custom-type', + ], + 'repositories' => array_values($repo), + ], + ]; + + $data['Filter by one repository'] = [ + [ + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + ], + [ + 'repositories' => array_values($repo), + ], + ['example.org/project-gamma'], + ]; + + $data['Filter by two repositories'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['gamma1'], + $packages['gamma2'], + $packages['gamma3'], + $packages['gamma4'], + ], + [ + 'repositories' => array_values($repo), + ], + ['example.org/project-gamma', 'example.org/project-alpha'], + ]; + + $data['Filter by two repositories and one package name'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + ], + [ + 'repositories' => array_values($repo), + ], + ['example.org/project-gamma', 'example.org/project-alpha'], + ['vendor/project-alpha'], + ]; + + $data['Filter by one package name'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + ], + [ + 'repositories' => array_values($repo), + ], + null, + ['vendor/project-alpha'], + ]; + + $data['Filter by two package names'] = [ + [ + $packages['alpha'], + $packages['alpha-dev'], + $packages['eta'], + ], + [ + 'repositories' => array_values($repo), + ], + null, + ['vendor/project-alpha', 'vendor/project-eta'], + ]; + + return $data; + } + + /** + * @param string[] $expected + * @param array $config + * @param string[]|null $filterRepos + * @param string[]|null $filterPackages + */ + #[DataProvider('dataSelect')] + public function testSelect(array $expected, array $config, ?array $filterRepos = null, ?array $filterPackages = null): void + { + if (null !== $filterRepos || null !== $filterPackages) { + // Need to be able to override the default package repository class to allow testing of the filter options. + // basically the following needs to work... + // $composer->getRepositoryManager()->setRepositoryClass('package', MockPackageSelectionPackageRepository::class); + // Simplest solution is probably for the hash in RepositoryFactory::manager() to be moved in to a static property. + + self::markTestIncomplete('Test cannot be completed.'); + } + + unset(Config::$defaultRepositories['packagist'], Config::$defaultRepositories['packagist.org']); + + $composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false); + + $selection = new PackageSelection(new NullOutput(), 'build', $config, false); + $selection->setRepositoriesFilter($filterRepos); + $selection->setPackagesFilter([]); + + $selection->select($composer, true); + + $selectionRef = new \ReflectionClass(PackageSelection::class); + $selected = $selectionRef->getProperty('selected'); + + \sort($expected, \SORT_STRING); + self::assertEquals($expected, \array_keys($selected->getValue($selection))); + } + + /** + * @return array + */ + public static function dataFilterRepos(): array + { + $packages = [ + 'alpha1' => [ + 'name' => 'vendor/project-alpha', + 'version' => '1.2.3.1', + 'source' => [ + 'url' => 'git@github.com:vendor/project-alpha.git', + 'type' => 'vcs', + 'reference' => '1.2.3.1', + ], + ], + 'alpha2' => [ + 'name' => 'vendor/project-alpha', + 'version' => '1.2.3.2', + 'source' => [ + 'url' => 'git@github.com:vendor/project-alpha.git', + 'type' => 'vcs', + 'reference' => '1.2.3.2', + ], + ], + 'beta1' => [ + 'name' => 'vendor/project-beta', + 'version' => '1.2.3.1', + 'source' => [ + 'url' => 'git@github.com:vendor/project-beta.git', + 'type' => 'vcs', + 'reference' => '1.2.3.1', + ], + ], + 'gamma1' => [ + 'name' => 'vendor/project-gamma', + 'version' => '1.2.3.1', + 'source' => [ + 'url' => 'git@github.com:vendor/project-gamma.git', + 'type' => 'vcs', + 'reference' => '1.2.3.1', + ], + ], + ]; + + $repo = [ + 'alpha_packages' => [ + 'type' => 'package', + 'package' => [ + $packages['alpha1'], + $packages['alpha2'], + ], + ], + 'beta_packages' => [ + 'type' => 'package', + 'package' => [ + $packages['beta1'], + ], + ], + 'gamma_packages' => [ + 'type' => 'package', + 'package' => [ + $packages['gamma1'], + ], + ], + ]; + + foreach ($packages as &$p) { + $p = $p['name'] . '-' . $p['version']; + } + + $data = []; + + $data['Filter by one repository alpha'] = [ + [ + $packages['alpha1'], + $packages['alpha2'], + ], + [ + 'repositories' => array_values($repo), + ], + ['git@github.com:vendor/project-alpha.git'], + ]; + + $data['Filter by one repository beta'] = [ + [ + $packages['beta1'], + ], + [ + 'repositories' => array_values($repo), + ], + ['git@github.com:vendor/project-beta.git'], + ]; + + $data['Filter by two repositories'] = [ + [ + $packages['alpha1'], + $packages['alpha2'], + $packages['beta1'], + ], + [ + 'repositories' => array_values($repo), + ], + ['git@github.com:vendor/project-beta.git', 'git@github.com:vendor/project-alpha.git'], + ]; + + return $data; + } + + /** + * @param string[] $expected + * @param array $config + * @param string[]|null $filterRepos + */ + #[DataProvider('dataFilterRepos')] + public function testFilterRepos(array $expected, array $config, ?array $filterRepos = null): void + { + unset(Config::$defaultRepositories['packagist'], Config::$defaultRepositories['packagist.org']); + + $composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false); + + $selection = new PackageSelection(new NullOutput(), 'build', $config, false); + $selection->setRepositoriesFilter($filterRepos); + $selection->setPackagesFilter([]); + + $selection->select($composer, true); + + $selectionRef = new \ReflectionClass(PackageSelection::class); + $selected = $selectionRef->getProperty('selected'); + + \sort($expected, \SORT_STRING); + self::assertEquals($expected, \array_keys($selected->getValue($selection))); + } + + /** + * @return array + */ + public static function dataMetadataSupport(): array + { + $vendorRepo = new ArrayRepository(); + + $vendorRepo->addPackage(new Package('vendor/name', '1.0.0.0', '1.0.0')); + $vendorRepo->addPackage(new Package('vendor/name', '1.0.0.0', '1.0.0+metadata-0')); + $vendorRepo->addPackage(new Package('vendor/name', '1.0.0.0', '1.0.0+metadata-1')); + $vendorRepo->addPackage(new Package('vendor/name', '1.0.0.0', '1.0.0+malformed+metadata')); + $vendorRepo->addPackage(new Package('vendor/name', '2.0.0.0', '2.0.0')); + + $data = []; + + $data['packages with metadata'] = [ + [ + 'vendor/name-1.0.0.0', + 'vendor/name-1.0.0.0+metadata-0', + 'vendor/name-1.0.0.0+metadata-1', + 'vendor/name-2.0.0.0', + ], + $vendorRepo, + ]; + + return $data; + } + + /** + * @param string[] $expected + */ + #[DataProvider('dataMetadataSupport')] + public function testMetadataSupport(array $expected, ArrayRepository $repository): void + { + unset(Config::$defaultRepositories['packagist'], Config::$defaultRepositories['packagist.org']); + + $composer = (new Factory())->createComposer(new NullIO(), [], true, null, false); + $composer->getRepositoryManager()->addRepository($repository); + $selection = new PackageSelection(new NullOutput(), 'build', [], false); + + $selectionRef = new \ReflectionClass(\get_class($selection)); + + $select = $selectionRef->getMethod('select'); + $result = $select->invokeArgs($selection, [$composer, true]); + + self::assertEquals($expected, array_keys($result)); + } + + /** + * @return array + */ + public static function dataClean(): array + { + $packages = [ + 'alpha' => [ + 'dist' => [ + 'type' => 'zip', + 'url' => 'http://127.0.0.1/output/dist/alpha.zip', + ], + 'source' => [ + 'type' => 'git', + 'url' => './git-repo', + ], + ], + 'beta' => [ + 'dist' => [ + 'type' => 'zip', + 'url' => 'http://192.168.0.2/output/dist/beta.zip', + ], + 'source' => [ + 'type' => 'git', + 'url' => 'http://localhost/beta.git', + ], + ], + 'gamma' => [ + 'dist' => [ + 'type' => 'zip', + 'url' => 'http://192.168.0.1/output/dist/gamma.zip', + ], + 'source' => [ + 'type' => 'git', + 'url' => 'http://192.168.1.1/gamma.git', + ], + ], + 'delta' => [ + 'dist' => [ + 'type' => 'zip', + 'url' => 'http://example.org/output/dist/delta.zip', + ], + 'source' => [ + 'type' => 'git', + 'url' => 'http://source.example.org/delta.git', + ], + ], + 'epsilon' => [ + 'dist' => [ + 'type' => 'zip', + 'url' => 'http://[abcd::]/output/dist/epsilon.zip', + ], + 'source' => [ + 'type' => 'git', + 'url' => 'http://[::1]/epsilon.git', + ], + ], + ]; + + $data = []; + $data['Keep everything'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', './git-repo'], + 'beta' => ['http://192.168.0.2/output/dist/beta.zip', 'http://localhost/beta.git'], + 'gamma' => ['http://192.168.0.1/output/dist/gamma.zip', 'http://192.168.1.1/gamma.git'], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', 'http://[::1]/epsilon.git'], + ], + [], + $packages, + ]; + + $data['Remove local file URLs'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', null], + 'beta' => ['http://192.168.0.2/output/dist/beta.zip', 'http://localhost/beta.git'], + 'gamma' => ['http://192.168.0.1/output/dist/gamma.zip', 'http://192.168.1.1/gamma.git'], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', 'http://[::1]/epsilon.git'], + ], + [ + 'strip-hosts' => true, + ], + $packages, + ]; + + $data['Remove local IPs'] = [ + [ + 'beta' => ['http://192.168.0.2/output/dist/beta.zip', null], + 'gamma' => ['http://192.168.0.1/output/dist/gamma.zip', 'http://192.168.1.1/gamma.git'], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', null], + ], + [ + 'strip-hosts' => ['/local'], + ], + $packages, + ]; + + $data['Remove private IPs'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', null], + 'beta' => [null, 'http://localhost/beta.git'], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', 'http://[::1]/epsilon.git'], + ], + [ + 'strip-hosts' => ['/private'], + ], + $packages, + ]; + + $data['Remove IPv4 with CIDR notation'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', null], + 'beta' => [null, 'http://localhost/beta.git'], + 'gamma' => [null, 'http://192.168.1.1/gamma.git'], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', 'http://[::1]/epsilon.git'], + ], + [ + 'strip-hosts' => ['192.168.0.0/24'], + ], + $packages, + ]; + + $data['Remove IPv6 address'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', null], + 'beta' => ['http://192.168.0.2/output/dist/beta.zip', 'http://localhost/beta.git'], + 'gamma' => ['http://192.168.0.1/output/dist/gamma.zip', 'http://192.168.1.1/gamma.git'], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => [null, 'http://[::1]/epsilon.git'], + ], + [ + 'strip-hosts' => ['abcd::'], + ], + $packages, + ]; + + $data['Remove domain'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', null], + 'beta' => ['http://192.168.0.2/output/dist/beta.zip', 'http://localhost/beta.git'], + 'gamma' => ['http://192.168.0.1/output/dist/gamma.zip', 'http://192.168.1.1/gamma.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', 'http://[::1]/epsilon.git'], + ], + [ + 'strip-hosts' => ['example.org'], + ], + $packages, + ]; + + $data['Preserve distURL from ArchiveBuilder'] = [ + [ + 'alpha' => ['http://127.0.0.1/output/dist/alpha.zip', null], + 'beta' => [null, 'http://localhost/beta.git'], + 'gamma' => ['http://192.168.0.1/output/dist/gamma.zip', null], + 'delta' => ['http://example.org/output/dist/delta.zip', 'http://source.example.org/delta.git'], + 'epsilon' => ['http://[abcd::]/output/dist/epsilon.zip', 'http://[::1]/epsilon.git'], + ], + [ + 'strip-hosts' => ['/private'], + 'archive' => [ + 'directory' => 'dist', + 'prefix-url' => 'http://192.168.0.1', + ], + ], + $packages, + ]; + + return $data; + } + + /** + * @param string[] $expected + * @param array $config + * @param array $packages + */ + #[DataProvider('dataClean')] + public function testClean(array $expected, array $config, array $packages): void + { + $selection = new PackageSelection(new NullOutput(), 'build', $config, false); + $selectionRef = new \ReflectionClass(\get_class($selection)); + + foreach ($packages as $i => $p) { + $packages[$i] = new Package($i, '1.0.0.0', '1.0'); + $packages[$i]->setDistType($p['dist']['type']); + $packages[$i]->setDistUrl($p['dist']['url']); + $packages[$i]->setSourceType($p['source']['type']); + $packages[$i]->setSourceUrl($p['source']['url']); + } + + $selected = $selectionRef->getProperty('selected'); + $selected->setValue($selection, $packages); + + $clean = $selectionRef->getMethod('clean'); + + $cleanPackages = $clean->invokeArgs($selection, []); + $sources = []; + foreach ($cleanPackages as $name => $package) { + $sources[$name] = [ + (null !== $package->getDistType()) ? $package->getDistUrl() : null, + (null !== $package->getSourceType()) ? $package->getSourceUrl() : null, + ]; + } + + self::assertEquals($expected, $sources); + } + + public static function testOnlyBestCandidates(): void + { + $repository = new ArrayRepository(); + + $packageA0 = new Package('vendor/a', '1.0.0.0', '1.0'); + $packageA1 = new Package('vendor/a', '1.1.0.0', '1.1'); + $packageB0 = new Package('vendor/b', '1.0.0.0', '1.0'); + $packageB1 = new Package('vendor/b', '1.1.0.0', '1.1'); + $packageB2 = new Package('vendor/b', '1.2.0.0', '1.2'); + $packageC0 = new Package('vendor/c', '1.0.0.0', '1.0'); + $packageC1 = new Package('vendor/c', '1.1.0.0', '1.1'); + + $constraint1 = new Constraint('=', '1.1'); + $link1 = new Link('vendor/a', 'vendor/b', $constraint1, Link::TYPE_REQUIRE); + $packageA0->setRequires([$link1->getTarget() => $link1]); + + $constraint2 = new Constraint('<=', '1.1'); + $link2 = new Link('vendor/b', 'vendor/c', $constraint2, Link::TYPE_REQUIRE); + $packageB1->setRequires([$link2->getTarget() => $link2]); + + $repository->addPackage($packageA0); + $repository->addPackage($packageA1); + $repository->addPackage($packageB0); + $repository->addPackage($packageB1); + $repository->addPackage($packageB2); + $repository->addPackage($packageC0); + $repository->addPackage($packageC1); + $repositorySet = new RepositorySet(); + $repositorySet->addRepository($repository); + + $rootConstraint = new Constraint('=', '1.0'); + $rootLink = new Link('top', 'vendor/a', $rootConstraint, Link::TYPE_REQUIRE); + + $config = [ + 'only-best-candidates' => true, + 'require-dependencies' => true, + ]; + $builder = new PackageSelection(new NullOutput(), 'build', $config, false); + $reflection = new \ReflectionClass(get_class($builder)); + $method = $reflection->getMethod('selectLinks'); + $method->invokeArgs($builder, [$repositorySet, [$rootLink], false, false]); + + $property = $reflection->getProperty('selected'); + + self::assertEquals([$packageA0, $packageB1, $packageC1], array_values($property->getValue($builder))); + } + + /** + * @return array + */ + public static function dataGetDepReposWithDisableDirectives(): array + { + $data = []; + + $data['disable directive only'] = [ + 0, + [ + 'repositories' => [], + 'repositories-dep' => [ + ['packagist.org' => false], + ], + 'require-all' => true, + ], + ]; + + $data['disable directive with valid repos'] = [ + 1, + [ + 'repositories' => [], + 'repositories-dep' => [ + ['packagist.org' => false], + [ + 'type' => 'package', + 'package' => [ + 'name' => 'vendor/dep-alpha', + 'version' => '1.0.0', + 'dist' => ['url' => 'https://example.com/alpha.zip', 'type' => 'zip'], + ], + ], + ], + 'require-all' => true, + ], + ]; + + $data['multiple disable directives mixed with valid repos'] = [ + 1, + [ + 'repositories' => [], + 'repositories-dep' => [ + ['packagist.org' => false], + ['my-private-repo' => false], + [ + 'type' => 'package', + 'package' => [ + 'name' => 'vendor/dep-beta', + 'version' => '2.0.0', + 'dist' => ['url' => 'https://example.com/beta.zip', 'type' => 'zip'], + ], + ], + ], + 'require-all' => true, + ], + ]; + + $data['no disable directives'] = [ + 2, + [ + 'repositories' => [], + 'repositories-dep' => [ + [ + 'type' => 'package', + 'package' => [ + 'name' => 'vendor/dep-alpha', + 'version' => '1.0.0', + 'dist' => ['url' => 'https://example.com/alpha.zip', 'type' => 'zip'], + ], + ], + [ + 'type' => 'package', + 'package' => [ + 'name' => 'vendor/dep-beta', + 'version' => '2.0.0', + 'dist' => ['url' => 'https://example.com/beta.zip', 'type' => 'zip'], + ], + ], + ], + 'require-all' => true, + ], + ]; + + return $data; + } + + /** + * @param array $config + */ + #[DataProvider('dataGetDepReposWithDisableDirectives')] + public function testGetDepReposSkipsDisableDirectives(int $expectedRepoCount, array $config): void + { + unset(Config::$defaultRepositories['packagist'], Config::$defaultRepositories['packagist.org']); + + $composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false); + + $selection = new PackageSelection(new NullOutput(), 'build', $config, false); + + $reflection = new \ReflectionClass(PackageSelection::class); + $method = $reflection->getMethod('getDepRepos'); + + $repos = $method->invokeArgs($selection, [$composer]); + + self::assertCount($expectedRepoCount, $repos); + + foreach ($repos as $repo) { + self::assertInstanceOf(\Composer\Repository\RepositoryInterface::class, $repo); + } + } + + /** + * @return array + */ + public static function dataSelectWithPackagesFilterOnLocalRepositories(): array + { + return [ + 'artifact repository' => [self::REPOSITORY_TYPE_ARTIFACT, 'vendor/artifact-package'], + 'path repository' => [self::REPOSITORY_TYPE_PATH, 'vendor/path-package'], + 'package repository' => [self::REPOSITORY_TYPE_PACKAGE, 'vendor/inline-package'], + ]; + } + + /** + * A partial update (`satis build satis.json web/ vendor/package`) must keep + * repositories Satis can read without any network access, even though those + * repositories carry no "name" in their config. + * + * @see https://github.com/composer/satis/issues/983 + */ + #[DataProvider('dataSelectWithPackagesFilterOnLocalRepositories')] + public function testSelectWithPackagesFilterKeepsLocalRepositories(string $repositoryType, string $packageName): void + { + $this->runPackagesFilterSelection( + fn (string $workDir): array => [$this->createLocalRepositoryConfig($repositoryType, $packageName, $workDir)], + [$packageName], + [$packageName] + ); + } + + /** + * The filter must still filter: a local repository that does not hold any of + * the requested packages has to be dropped like any other. + * + * @see https://github.com/composer/satis/issues/983 + */ + public function testSelectWithPackagesFilterDropsLocalRepositoriesWithoutTheFilteredPackage(): void + { + $wantedPackage = 'vendor/wanted-package'; + $otherPackage = 'vendor/other-package'; + + $this->runPackagesFilterSelection( + fn (string $workDir): array => [ + $this->createLocalRepositoryConfig(self::REPOSITORY_TYPE_ARTIFACT, $wantedPackage, $workDir), + $this->createLocalRepositoryConfig(self::REPOSITORY_TYPE_ARTIFACT, $otherPackage, $workDir), + ], + [$wantedPackage], + [$wantedPackage] + ); + } + + /** + * Regression guard for the pre-existing behaviour: a repository declaring a + * matching "name" in its Satis config is still selected through that name. + * This one already passes without the local-repository matching. + */ + public function testSelectWithPackagesFilterStillMatchesTheRepositoryConfigName(): void + { + $packageName = 'vendor/named-package'; + + $this->runPackagesFilterSelection( + function (string $workDir) use ($packageName): array { + $repository = $this->createLocalRepositoryConfig(self::REPOSITORY_TYPE_ARTIFACT, $packageName, $workDir); + $repository['name'] = $packageName; + + return [$repository]; + }, + [$packageName], + [$packageName] + ); + } + + /** + * Runs `select()` with a packages filter over a throwaway working directory + * and asserts which packages came out. + * + * @param callable(string): list> $buildRepositories + * @param list $packagesFilter + * @param list $expectedPackageNames + */ + private function runPackagesFilterSelection( + callable $buildRepositories, + array $packagesFilter, + array $expectedPackageNames, + ): void { + $filesystem = new Filesystem(); + $workDir = sys_get_temp_dir() . '/satis-package-filter-' . uniqid('', true); + $filesystem->ensureDirectoryExists($workDir); + + try { + $config = [ + 'name' => 'test/satis', + 'homepage' => 'http://localhost', + 'repositories' => $buildRepositories($workDir), + 'require-all' => true, + ]; + + unset(Config::$defaultRepositories['packagist'], Config::$defaultRepositories['packagist.org']); + + $composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false); + + $selection = new PackageSelection(new NullOutput(), $workDir . '/build', $config, false); + $selection->setPackagesFilter($packagesFilter); + + $selected = $selection->select($composer, false); + + $expected = array_map( + static fn (string $name): string => $name . '-' . self::FIXTURE_NORMALIZED_VERSION, + $expectedPackageNames + ); + self::assertSame($expected, array_keys($selected)); + } finally { + $filesystem->removeDirectory($workDir); + } + } + + /** + * Builds the repository fixture on disk and returns its Satis config entry. + * + * @return array + */ + private function createLocalRepositoryConfig(string $repositoryType, string $packageName, string $workDir): array + { + $composerJson = json_encode( + ['name' => $packageName, 'version' => self::FIXTURE_VERSION], + \JSON_THROW_ON_ERROR + ); + + if (self::REPOSITORY_TYPE_PACKAGE === $repositoryType) { + return [ + 'type' => self::REPOSITORY_TYPE_PACKAGE, + 'package' => ['name' => $packageName, 'version' => self::FIXTURE_VERSION], + ]; + } + + $filesystem = new Filesystem(); + // One directory per package: an `artifact` repository is a directory, so + // two of them must not end up sharing one. + $repositoryDir = sprintf('%s/%s/%s', $workDir, $repositoryType, str_replace('/', '-', $packageName)); + $filesystem->ensureDirectoryExists($repositoryDir); + + if (self::REPOSITORY_TYPE_PATH === $repositoryType) { + if (false === file_put_contents($repositoryDir . '/composer.json', $composerJson)) { + self::fail('Could not write the path repository fixture in ' . $repositoryDir); + } + + return ['type' => self::REPOSITORY_TYPE_PATH, 'url' => $repositoryDir]; + } + + $archivePath = sprintf( + '%s/%s-%s.zip', + $repositoryDir, + str_replace('/', '-', $packageName), + self::FIXTURE_VERSION + ); + $archive = new \ZipArchive(); + if (true !== $archive->open($archivePath, \ZipArchive::CREATE)) { + self::fail('Could not create the artifact repository fixture ' . $archivePath); + } + + try { + $archive->addFromString('composer.json', $composerJson); + } finally { + $archive->close(); + } + + return ['type' => self::REPOSITORY_TYPE_ARTIFACT, 'url' => $repositoryDir]; + } +} + +final class MockPackageSelectionPackageRepository extends PackageRepository implements ConfigurableRepositoryInterface +{ + /** @var string */ + private $name; + + /** @var string */ + private $url; + + /* + * Constructor. + * + * @param array{package: mixed[], url?: string} $config package definition + */ + public function __construct(array $config) + { + $this->name = $config['package']['name'] ?? $config['package'][0]['name']; + $configWithUrl = array_merge(['url' => ''], $config); + $this->url = $configWithUrl['url']; + + parent::__construct($config); + } + + /** + * @return array + */ + public function getRepoConfig(): array + { + return ['name' => $this->name, 'url' => $this->url]; + } +}