From 3dc279cf66c9329bf84bb31086e90a32cb2c8628 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 6 Nov 2024 13:49:06 +0100 Subject: [PATCH] Stop relying on OS to find executables on Windows, and migrate most Process calls to array syntax (#12180) Co-authored-by: Jordi Boggiano --- composer.lock | 48 ++-- phpstan/baseline.neon | 65 +---- src/Composer/Command/DiagnoseCommand.php | 2 +- src/Composer/Command/HomeCommand.php | 12 +- src/Composer/Command/InitCommand.php | 11 +- src/Composer/Compiler.php | 22 +- src/Composer/Downloader/FossilDownloader.php | 48 ++-- src/Composer/Downloader/GitDownloader.php | 140 ++++----- src/Composer/Downloader/GzipDownloader.php | 4 +- src/Composer/Downloader/HgDownloader.php | 23 +- src/Composer/Downloader/RarDownloader.php | 4 +- src/Composer/Downloader/SvnDownloader.php | 24 +- src/Composer/Downloader/XzDownloader.php | 4 +- src/Composer/Downloader/ZipDownloader.php | 34 ++- src/Composer/Package/Locker.php | 5 +- .../Package/Version/VersionGuesser.php | 12 +- src/Composer/Platform/HhvmDetector.php | 6 +- src/Composer/Repository/PathRepository.php | 6 +- src/Composer/Repository/Vcs/FossilDriver.php | 25 +- src/Composer/Repository/Vcs/GitDriver.php | 20 +- src/Composer/Repository/Vcs/HgDriver.php | 27 +- src/Composer/Repository/Vcs/SvnDriver.php | 19 +- src/Composer/Util/Bitbucket.php | 2 +- src/Composer/Util/Filesystem.php | 20 +- src/Composer/Util/Git.php | 159 ++++++++--- src/Composer/Util/GitHub.php | 4 +- src/Composer/Util/GitLab.php | 4 +- src/Composer/Util/Hg.php | 2 +- src/Composer/Util/Perforce.php | 22 +- src/Composer/Util/Platform.php | 15 +- src/Composer/Util/ProcessExecutor.php | 52 +++- src/Composer/Util/Svn.php | 54 ++-- tests/Composer/Test/AllFunctionalTest.php | 1 + .../Test/Downloader/FossilDownloaderTest.php | 13 +- .../Test/Downloader/GitDownloaderTest.php | 268 ++++++++++-------- .../Test/Downloader/HgDownloaderTest.php | 11 +- .../Test/Mock/ProcessExecutorMock.php | 6 +- .../Package/Version/VersionGuesserTest.php | 20 +- .../Test/Repository/Vcs/GitDriverTest.php | 16 +- .../Test/Repository/Vcs/GitHubDriverTest.php | 10 +- .../Test/Repository/Vcs/HgDriverTest.php | 4 +- .../Test/Repository/Vcs/SvnDriverTest.php | 9 +- tests/Composer/Test/Util/GitTest.php | 11 +- tests/Composer/Test/Util/PerforceTest.php | 6 +- tests/Composer/Test/Util/SvnTest.php | 28 +- 45 files changed, 714 insertions(+), 584 deletions(-) diff --git a/composer.lock b/composer.lock index b07c8411a..10d57e9f2 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "composer/ca-bundle", - "version": "1.5.2", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "48a792895a2b7a6ee65dd5442c299d7b835b6137" + "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/48a792895a2b7a6ee65dd5442c299d7b835b6137", - "reference": "48a792895a2b7a6ee65dd5442c299d7b835b6137", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/3b1fc3f0be055baa7c6258b1467849c3e8204eb2", + "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2", "shasum": "" }, "require": { @@ -64,7 +64,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.2" + "source": "https://github.com/composer/ca-bundle/tree/1.5.3" }, "funding": [ { @@ -80,7 +80,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T07:49:53+00:00" + "time": "2024-11-04T10:15:26+00:00" }, { "name": "composer/class-map-generator", @@ -941,16 +941,16 @@ }, { "name": "symfony/console", - "version": "v5.4.45", + "version": "v5.4.46", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "108d436c2af470858bdaba3257baab3a74172017" + "reference": "fb0d4760e7147d81ab4d9e2d57d56268261b4e4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/108d436c2af470858bdaba3257baab3a74172017", - "reference": "108d436c2af470858bdaba3257baab3a74172017", + "url": "https://api.github.com/repos/symfony/console/zipball/fb0d4760e7147d81ab4d9e2d57d56268261b4e4e", + "reference": "fb0d4760e7147d81ab4d9e2d57d56268261b4e4e", "shasum": "" }, "require": { @@ -1020,7 +1020,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.45" + "source": "https://github.com/symfony/console/tree/v5.4.46" }, "funding": [ { @@ -1036,7 +1036,7 @@ "type": "tidelift" } ], - "time": "2024-10-08T07:27:17+00:00" + "time": "2024-11-05T14:17:06+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1787,16 +1787,16 @@ }, { "name": "symfony/process", - "version": "v5.4.45", + "version": "v5.4.46", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "95f3f19d0f8f06e4253c66a0828ddb69f8b8ede4" + "reference": "01906871cb9b5e3cf872863b91aba4ec9767daf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/95f3f19d0f8f06e4253c66a0828ddb69f8b8ede4", - "reference": "95f3f19d0f8f06e4253c66a0828ddb69f8b8ede4", + "url": "https://api.github.com/repos/symfony/process/zipball/01906871cb9b5e3cf872863b91aba4ec9767daf4", + "reference": "01906871cb9b5e3cf872863b91aba4ec9767daf4", "shasum": "" }, "require": { @@ -1829,7 +1829,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.45" + "source": "https://github.com/symfony/process/tree/v5.4.46" }, "funding": [ { @@ -1845,7 +1845,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:11:13+00:00" + "time": "2024-11-06T09:18:28+00:00" }, { "name": "symfony/service-contracts", @@ -2226,16 +2226,16 @@ }, { "name": "phpstan/phpstan-symfony", - "version": "1.4.10", + "version": "1.4.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "f7d5782044bedf93aeb3f38e09c91148ee90e5a1" + "reference": "270c2ee1478d1f8dc5121f539e890017bd64b04c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/f7d5782044bedf93aeb3f38e09c91148ee90e5a1", - "reference": "f7d5782044bedf93aeb3f38e09c91148ee90e5a1", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/270c2ee1478d1f8dc5121f539e890017bd64b04c", + "reference": "270c2ee1478d1f8dc5121f539e890017bd64b04c", "shasum": "" }, "require": { @@ -2292,9 +2292,9 @@ "description": "Symfony Framework extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.10" + "source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.11" }, - "time": "2024-09-26T18:14:50+00:00" + "time": "2024-10-30T12:07:21+00:00" }, { "name": "symfony/phpunit-bridge", diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index 78f5ce38c..7a89f1d83 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -300,11 +300,6 @@ parameters: count: 1 path: ../src/Composer/Command/CreateProjectCommand.php - - - message: "#^Parameter \\#3 \\$existingRepos of static method Composer\\\\Repository\\\\RepositoryFactory\\:\\:generateRepositoryName\\(\\) expects array\\, array\\ given\\.$#" - count: 1 - path: ../src/Composer/Command/CreateProjectCommand.php - - message: "#^Variable method call on Composer\\\\Package\\\\RootPackageInterface\\.$#" count: 1 @@ -495,11 +490,6 @@ parameters: count: 2 path: ../src/Composer/Command/LicensesCommand.php - - - message: "#^Only booleans are allowed in a negated boolean, array\\ given\\.$#" - count: 1 - path: ../src/Composer/Command/ReinstallCommand.php - - message: "#^Foreach overwrites \\$type with its key variable\\.$#" count: 1 @@ -955,11 +945,6 @@ parameters: count: 1 path: ../src/Composer/Config.php - - - message: "#^Only booleans are allowed in a ternary operator condition, string\\|null given\\.$#" - count: 1 - path: ../src/Composer/Config.php - - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" count: 1 @@ -1160,11 +1145,6 @@ parameters: count: 1 path: ../src/Composer/Downloader/FileDownloader.php - - - message: "#^Parameter \\#3 \\$cwd of method Composer\\\\Util\\\\ProcessExecutor\\:\\:execute\\(\\) expects string\\|null, string\\|false given\\.$#" - count: 5 - path: ../src/Composer/Downloader/FossilDownloader.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" count: 5 @@ -1367,7 +1347,7 @@ parameters: - message: "#^Only booleans are allowed in a negated boolean, array\\\\> given\\.$#" - count: 3 + count: 2 path: ../src/Composer/Downloader/ZipDownloader.php - @@ -2330,11 +2310,6 @@ parameters: count: 2 path: ../src/Composer/Question/StrictConfirmationQuestion.php - - - message: "#^Method Composer\\\\Repository\\\\ArrayRepository\\:\\:getProviders\\(\\) should return array\\ but returns array\\\\.$#" - count: 1 - path: ../src/Composer/Repository/ArrayRepository.php - - message: "#^Only booleans are allowed in a negated boolean, Composer\\\\Semver\\\\Constraint\\\\ConstraintInterface\\|null given\\.$#" count: 1 @@ -2381,7 +2356,7 @@ parameters: path: ../src/Composer/Repository/ComposerRepository.php - - message: "#^Method Composer\\\\Repository\\\\ComposerRepository\\:\\:getProviders\\(\\) should return array\\ but returns array\\\\.$#" + message: "#^Method Composer\\\\Repository\\\\ComposerRepository\\:\\:getProviders\\(\\) should return array\\ but returns array\\\\.$#" count: 1 path: ../src/Composer/Repository/ComposerRepository.php @@ -2496,7 +2471,7 @@ parameters: path: ../src/Composer/Repository/CompositeRepository.php - - message: "#^Only booleans are allowed in a ternary operator condition, array\\\\>\\> given\\.$#" + message: "#^Only booleans are allowed in a ternary operator condition, array\\\\>\\> given\\.$#" count: 1 path: ../src/Composer/Repository/CompositeRepository.php @@ -2714,7 +2689,7 @@ parameters: path: ../src/Composer/Repository/RepositorySet.php - - message: "#^Only booleans are allowed in an if condition, array\\\\> given\\.$#" + message: "#^Only booleans are allowed in an if condition, array\\\\> given\\.$#" count: 1 path: ../src/Composer/Repository/RepositorySet.php @@ -2723,21 +2698,6 @@ parameters: count: 1 path: ../src/Composer/Repository/RepositorySet.php - - - message: "#^Only booleans are allowed in a negated boolean, string given\\.$#" - count: 1 - path: ../src/Composer/Repository/Vcs/FossilDriver.php - - - - message: "#^Parameter \\#1 \\$file of method Composer\\\\Util\\\\Filesystem\\:\\:remove\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: ../src/Composer/Repository/Vcs/FossilDriver.php - - - - message: "#^Parameter \\#1 \\$filename of function is_file expects string, string\\|null given\\.$#" - count: 1 - path: ../src/Composer/Repository/Vcs/FossilDriver.php - - message: "#^Call to function array_search\\(\\) requires parameter \\#3 to be set\\.$#" count: 2 @@ -3968,11 +3928,6 @@ parameters: count: 1 path: ../src/Composer/Util/Svn.php - - - message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#" - count: 1 - path: ../src/Composer/Util/Svn.php - - message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#" count: 1 @@ -4341,6 +4296,11 @@ parameters: count: 2 path: ../tests/Composer/Test/Mock/ProcessExecutorMock.php + - + message: "#^Property Composer\\\\Test\\\\Mock\\\\ProcessExecutorMock\\:\\:\\$expectations \\(array\\\\|string, return\\: int, stdout\\: string, stderr\\: string, callback\\: \\(callable\\(\\)\\: mixed\\)\\|null\\}\\>\\|null\\) does not accept array\\, non\\-empty\\-list\\\\|\\(callable\\(\\)\\: mixed\\)\\|int\\|string\\>\\|\\(callable\\(\\)\\: mixed\\)\\|int\\|string\\|null\\>\\>\\.$#" + count: 1 + path: ../tests/Composer/Test/Mock/ProcessExecutorMock.php + - message: "#^Composer\\\\Test\\\\Mock\\\\VersionGuesserMock\\:\\:__construct\\(\\) does not call parent constructor from Composer\\\\Package\\\\Version\\\\VersionGuesser\\.$#" count: 1 @@ -4486,9 +4446,14 @@ parameters: count: 1 path: ../tests/Composer/Test/TestCase.php + - + message: "#^Cannot access an offset on array\\\\|int\\|string\\>\\>\\|false\\.$#" + count: 2 + path: ../tests/Composer/Test/Util/GitTest.php + - message: "#^Cannot access an offset on array\\\\>\\|false\\.$#" - count: 3 + count: 1 path: ../tests/Composer/Test/Util/GitTest.php - diff --git a/src/Composer/Command/DiagnoseCommand.php b/src/Composer/Command/DiagnoseCommand.php index 263ee0644..7dade55ef 100644 --- a/src/Composer/Command/DiagnoseCommand.php +++ b/src/Composer/Command/DiagnoseCommand.php @@ -302,7 +302,7 @@ EOT return 'proc_open is not available, git cannot be used'; } - $this->process->execute('git config color.ui', $output); + $this->process->execute(['git', 'config', 'color.ui'], $output); if (strtolower(trim($output)) === 'always') { return 'Your git color.ui setting is set to always, this is known to create issues. Use "git config --global color.ui true" to set it correctly.'; } diff --git a/src/Composer/Command/HomeCommand.php b/src/Composer/Command/HomeCommand.php index 5aec6f4c1..3547faec7 100644 --- a/src/Composer/Command/HomeCommand.php +++ b/src/Composer/Command/HomeCommand.php @@ -122,22 +122,20 @@ EOT */ private function openBrowser(string $url): void { - $url = ProcessExecutor::escape($url); - $process = new ProcessExecutor($this->getIO()); if (Platform::isWindows()) { - $process->execute('start "web" explorer ' . $url, $output); + $process->execute(['start', '"web"', 'explorer', $url], $output); return; } - $linux = $process->execute('which xdg-open', $output); - $osx = $process->execute('which open', $output); + $linux = $process->execute(['which', 'xdg-open'], $output); + $osx = $process->execute(['which', 'open'], $output); if (0 === $linux) { - $process->execute('xdg-open ' . $url, $output); + $process->execute(['xdg-open', $url], $output); } elseif (0 === $osx) { - $process->execute('open ' . $url, $output); + $process->execute(['open', $url], $output); } else { $this->getIO()->writeError('No suitable browser opening command found, open yourself: ' . $url); } diff --git a/src/Composer/Command/InitCommand.php b/src/Composer/Command/InitCommand.php index 6d7311dd4..8b01dda6f 100644 --- a/src/Composer/Command/InitCommand.php +++ b/src/Composer/Command/InitCommand.php @@ -27,6 +27,7 @@ use Composer\Util\Silencer; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Composer\Console\Input\InputOption; +use Composer\Util\ProcessExecutor; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; @@ -535,15 +536,11 @@ EOT return $this->gitConfig; } - $finder = new ExecutableFinder(); - $gitBin = $finder->find('git'); + $process = new ProcessExecutor($this->getIO()); - $cmd = new Process([$gitBin, 'config', '-l']); - $cmd->run(); - - if ($cmd->isSuccessful()) { + if (0 === $process->execute(['git', 'config', '-l'], $output)) { $this->gitConfig = []; - Preg::matchAllStrictGroups('{^([^=]+)=(.*)$}m', $cmd->getOutput(), $matches); + Preg::matchAllStrictGroups('{^([^=]+)=(.*)$}m', $output, $matches); foreach ($matches[1] as $key => $match) { $this->gitConfig[$match] = $matches[2][$key]; } diff --git a/src/Composer/Compiler.php b/src/Composer/Compiler.php index def3ff8c4..e85329c6d 100644 --- a/src/Composer/Compiler.php +++ b/src/Composer/Compiler.php @@ -15,6 +15,7 @@ namespace Composer; use Composer\Json\JsonFile; use Composer\CaBundle\CaBundle; use Composer\Pcre\Preg; +use Composer\Util\ProcessExecutor; use Symfony\Component\Finder\Finder; use Symfony\Component\Process\Process; use Seld\PharUtils\Timestamps; @@ -48,23 +49,22 @@ class Compiler unlink($pharFile); } - $process = new Process(['git', 'log', '--pretty=%H', '-n1', 'HEAD'], __DIR__); - if ($process->run() !== 0) { + $process = new ProcessExecutor(); + + if (0 !== $process->execute(['git', 'log', '--pretty=%H', '-n1', 'HEAD'], $output, __DIR__)) { throw new \RuntimeException('Can\'t run git log. You must ensure to run compile from composer git repository clone and that git binary is available.'); } - $this->version = trim($process->getOutput()); + $this->version = trim($output); - $process = new Process(['git', 'log', '-n1', '--pretty=%ci', 'HEAD'], __DIR__); - if ($process->run() !== 0) { + if (0 !== $process->execute(['git', 'log', '-n1', '--pretty=%ci', 'HEAD'], $output, __DIR__)) { throw new \RuntimeException('Can\'t run git log. You must ensure to run compile from composer git repository clone and that git binary is available.'); } - $this->versionDate = new \DateTime(trim($process->getOutput())); + $this->versionDate = new \DateTime(trim($output)); $this->versionDate->setTimezone(new \DateTimeZone('UTC')); - $process = new Process(['git', 'describe', '--tags', '--exact-match', 'HEAD'], __DIR__); - if ($process->run() === 0) { - $this->version = trim($process->getOutput()); + if (0 === $process->execute(['git', 'describe', '--tags', '--exact-match', 'HEAD'], $output, __DIR__)) { + $this->version = trim($output); } else { // get branch-alias defined in composer.json for dev-main (if any) $localConfig = __DIR__.'/../../composer.json'; @@ -75,6 +75,10 @@ class Compiler } } + if ('' === $this->version) { + throw new \UnexpectedValueException('Version detection failed'); + } + $phar = new \Phar($pharFile, 0, 'composer.phar'); $phar->setSignatureAlgorithm(\Phar::SHA512); diff --git a/src/Composer/Downloader/FossilDownloader.php b/src/Composer/Downloader/FossilDownloader.php index 6634771dc..60c6ed49d 100644 --- a/src/Composer/Downloader/FossilDownloader.php +++ b/src/Composer/Downloader/FossilDownloader.php @@ -12,10 +12,12 @@ namespace Composer\Downloader; +use Composer\Util\Platform; use React\Promise\PromiseInterface; use Composer\Package\PackageInterface; use Composer\Pcre\Preg; use Composer\Util\ProcessExecutor; +use RuntimeException; /** * @author BohwaZ @@ -38,22 +40,13 @@ class FossilDownloader extends VcsDownloader // Ensure we are allowed to use this URL by config $this->config->prohibitUrlByConfig($url, $this->io); - $url = ProcessExecutor::escape($url); - $ref = ProcessExecutor::escape($package->getSourceReference()); $repoFile = $path . '.fossil'; + $realPath = Platform::realpath($path); + $this->io->writeError("Cloning ".$package->getSourceReference()); - $command = sprintf('fossil clone -- %s %s', $url, ProcessExecutor::escape($repoFile)); - if (0 !== $this->process->execute($command, $ignoredOutput)) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); - } - $command = sprintf('fossil open --nested -- %s', ProcessExecutor::escape($repoFile)); - if (0 !== $this->process->execute($command, $ignoredOutput, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); - } - $command = sprintf('fossil update -- %s', $ref); - if (0 !== $this->process->execute($command, $ignoredOutput, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); - } + $this->execute(['fossil', 'clone', '--', $url, $repoFile]); + $this->execute(['fossil', 'open', '--nested', '--', $repoFile], $realPath); + $this->execute(['fossil', 'update', '--', (string) $package->getSourceReference()], $realPath); return \React\Promise\resolve(null); } @@ -66,17 +59,15 @@ class FossilDownloader extends VcsDownloader // Ensure we are allowed to use this URL by config $this->config->prohibitUrlByConfig($url, $this->io); - $ref = ProcessExecutor::escape($target->getSourceReference()); $this->io->writeError(" Updating to ".$target->getSourceReference()); if (!$this->hasMetadataRepository($path)) { throw new \RuntimeException('The .fslckout file is missing from '.$path.', see https://getcomposer.org/commit-deps for more information'); } - $command = sprintf('fossil pull && fossil up %s', $ref); - if (0 !== $this->process->execute($command, $ignoredOutput, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); - } + $realPath = Platform::realpath($path); + $this->execute(['fossil', 'pull'], $realPath); + $this->execute(['fossil', 'up', (string) $target->getSourceReference()], $realPath); return \React\Promise\resolve(null); } @@ -90,7 +81,7 @@ class FossilDownloader extends VcsDownloader return null; } - $this->process->execute('fossil changes', $output, realpath($path)); + $this->process->execute(['fossil', 'changes'], $output, Platform::realpath($path)); $output = trim($output); @@ -102,11 +93,7 @@ class FossilDownloader extends VcsDownloader */ protected function getCommitLogs(string $fromReference, string $toReference, string $path): string { - $command = sprintf('fossil timeline -t ci -W 0 -n 0 before %s', ProcessExecutor::escape($toReference)); - - if (0 !== $this->process->execute($command, $output, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); - } + $this->execute(['fossil', 'timeline', '-t', 'ci', '-W', '0', '-n', '0', 'before', $toReference], Platform::realpath($path), $output); $log = ''; $match = '/\d\d:\d\d:\d\d\s+\[' . $toReference . '\]/'; @@ -121,6 +108,17 @@ class FossilDownloader extends VcsDownloader return $log; } + /** + * @param non-empty-list $command + * @throws \RuntimeException + */ + private function execute(array $command, ?string $cwd = null, ?string &$output = null): void + { + if (0 !== $this->process->execute($command, $output, $cwd)) { + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); + } + } + /** * @inheritDoc */ diff --git a/src/Composer/Downloader/GitDownloader.php b/src/Composer/Downloader/GitDownloader.php index f29d74522..e54d95473 100644 --- a/src/Composer/Downloader/GitDownloader.php +++ b/src/Composer/Downloader/GitDownloader.php @@ -73,7 +73,7 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface // --dissociate option is only available since git 2.3.0-rc0 if ($gitVersion && version_compare($gitVersion, '2.3.0-rc0', '>=') && Cache::isUsable($cachePath)) { $this->io->writeError(" - Syncing " . $package->getName() . " (" . $package->getFullPrettyVersion() . ") into cache"); - $this->io->writeError(sprintf(' Cloning to cache at %s', ProcessExecutor::escape($cachePath)), true, IOInterface::DEBUG); + $this->io->writeError(sprintf(' Cloning to cache at %s', $cachePath), true, IOInterface::DEBUG); $ref = $package->getSourceReference(); if ($this->gitUtil->fetchRefOrSyncMirror($url, $cachePath, $ref, $package->getPrettyVersion()) && is_dir($cachePath)) { $this->cachedPackages[$package->getId()][$ref] = true; @@ -94,24 +94,30 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface $path = $this->normalizePath($path); $cachePath = $this->config->get('cache-vcs-dir').'/'.Preg::replace('{[^a-z0-9.]}i', '-', Url::sanitize($url)).'/'; $ref = $package->getSourceReference(); - $flag = Platform::isWindows() ? '/D ' : ''; if (!empty($this->cachedPackages[$package->getId()][$ref])) { $msg = "Cloning ".$this->getShortHash($ref).' from cache'; - $cloneFlags = '--dissociate --reference %cachePath% '; + $cloneFlags = ['--dissociate', '--reference', $cachePath]; $transportOptions = $package->getTransportOptions(); if (isset($transportOptions['git']['single_use_clone']) && $transportOptions['git']['single_use_clone']) { - $cloneFlags = ''; + $cloneFlags = []; } - $command = - 'git clone --no-checkout %cachePath% %path% ' . $cloneFlags - . '&& cd '.$flag.'%path% ' - . '&& git remote set-url origin -- %sanitizedUrl% && git remote add composer -- %sanitizedUrl%'; + $commands = [ + array_merge(['git', 'clone', '--no-checkout', $cachePath, $path], $cloneFlags), + ['git', 'remote', 'set-url', 'origin', '--', '%sanitizedUrl%'], + ['git', 'remote', 'add', 'composer', '--', '%sanitizedUrl%'], + ]; } else { $msg = "Cloning ".$this->getShortHash($ref); - $command = 'git clone --no-checkout -- %url% %path% && cd '.$flag.'%path% && git remote add composer -- %url% && git fetch composer && git remote set-url origin -- %sanitizedUrl% && git remote set-url composer -- %sanitizedUrl%'; + $commands = [ + array_merge(['git', 'clone', '--no-checkout', '--', '%url%', $path]), + ['git', 'remote', 'add', 'composer', '--', '%url%'], + ['git', 'fetch', 'composer'], + ['git', 'remote', 'set-url', 'origin', '--', '%sanitizedUrl%'], + ['git', 'remote', 'set-url', 'composer', '--', '%sanitizedUrl%'], + ]; if (Platform::getEnv('COMPOSER_DISABLE_NETWORK')) { throw new \RuntimeException('The required git reference for '.$package->getName().' is not in cache and network is disabled, aborting'); } @@ -119,20 +125,8 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface $this->io->writeError($msg); - $commandCallable = static function (string $url) use ($path, $command, $cachePath): string { - return str_replace( - ['%url%', '%path%', '%cachePath%', '%sanitizedUrl%'], - [ - ProcessExecutor::escape($url), - ProcessExecutor::escape($path), - ProcessExecutor::escape($cachePath), - ProcessExecutor::escape(Preg::replace('{://([^@]+?):(.+?)@}', '://', $url)), - ], - $command - ); - }; + $this->gitUtil->runCommands($commands, $url, $path, true); - $this->gitUtil->runCommand($commandCallable, $url, $path, true); $sourceUrl = $package->getSourceUrl(); if ($url !== $sourceUrl && $sourceUrl !== null) { $this->updateOriginUrl($path, $sourceUrl); @@ -166,10 +160,10 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface if (!empty($this->cachedPackages[$target->getId()][$ref])) { $msg = "Checking out ".$this->getShortHash($ref).' from cache'; - $command = '(git rev-parse --quiet --verify %ref% || (git remote set-url composer -- %cachePath% && git fetch composer && git fetch --tags composer)) && git remote set-url composer -- %sanitizedUrl%'; + $remoteUrl = $cachePath; } else { $msg = "Checking out ".$this->getShortHash($ref); - $command = '(git remote set-url composer -- %url% && git rev-parse --quiet --verify %ref% || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- %sanitizedUrl%'; + $remoteUrl = '%url%'; if (Platform::getEnv('COMPOSER_DISABLE_NETWORK')) { throw new \RuntimeException('The required git reference for '.$target->getName().' is not in cache and network is disabled, aborting'); } @@ -177,20 +171,19 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface $this->io->writeError($msg); - $commandCallable = static function ($url) use ($ref, $command, $cachePath): string { - return str_replace( - ['%url%', '%ref%', '%cachePath%', '%sanitizedUrl%'], - [ - ProcessExecutor::escape($url), - ProcessExecutor::escape($ref.'^{commit}'), - ProcessExecutor::escape($cachePath), - ProcessExecutor::escape(Preg::replace('{://([^@]+?):(.+?)@}', '://', $url)), - ], - $command - ); - }; + if (0 !== $this->process->execute(['git', 'rev-parse', '--quiet', '--verify', $ref.'^{commit}'], $output, $path)) { + $commands = [ + ['git', 'remote', 'set-url', 'composer', '--', $remoteUrl], + ['git', 'fetch', 'composer'], + ['git', 'fetch', '--tags', 'composer'], + ]; + + $this->gitUtil->runCommands($commands, $url, $path); + } + + $command = ['git', 'remote', 'set-url', 'composer', '--', '%sanitizedUrl%']; + $this->gitUtil->runCommands([$command], $url, $path); - $this->gitUtil->runCommand($commandCallable, $url, $path); if ($newRef = $this->updateToCommit($target, $path, (string) $ref, $target->getPrettyVersion())) { if ($target->getDistReference() === $target->getSourceReference()) { $target->setDistReference($newRef); @@ -200,7 +193,7 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface $updateOriginUrl = false; if ( - 0 === $this->process->execute('git remote -v', $output, $path) + 0 === $this->process->execute(['git', 'remote', '-v'], $output, $path) && Preg::isMatch('{^origin\s+(?P\S+)}m', $output, $originMatch) && Preg::isMatch('{^composer\s+(?P\S+)}m', $output, $composerMatch) ) { @@ -225,9 +218,9 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface return null; } - $command = 'git status --porcelain --untracked-files=no'; + $command = ['git', 'status', '--porcelain', '--untracked-files=no']; if (0 !== $this->process->execute($command, $output, $path)) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); } $output = trim($output); @@ -243,9 +236,9 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface return null; } - $command = 'git show-ref --head -d'; + $command = ['git', 'show-ref', '--head', '-d']; if (0 !== $this->process->execute($command, $output, $path)) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); } $refs = trim($output); @@ -310,12 +303,12 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface // first pass and we found unpushed changes, fetch from all remotes to make sure we have up to date // remotes and then try again as outdated remotes can sometimes cause false-positives if ($unpushedChanges && $i === 0) { - $this->process->execute('git fetch --all', $output, $path); + $this->process->execute(['git', 'fetch', '--all'], $output, $path); // update list of refs after fetching - $command = 'git show-ref --head -d'; + $command = ['git', 'show-ref', '--head', '-d']; if (0 !== $this->process->execute($command, $output, $path)) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); } $refs = trim($output); } @@ -425,7 +418,7 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface if (!empty($this->hasStashedChanges[$path])) { unset($this->hasStashedChanges[$path]); $this->io->writeError(' Re-applying stashed changes'); - if (0 !== $this->process->execute('git stash pop', $output, $path)) { + if (0 !== $this->process->execute(['git', 'stash', 'pop'], $output, $path)) { throw new \RuntimeException("Failed to apply stashed changes:\n\n".$this->process->getErrorOutput()); } } @@ -441,18 +434,29 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface */ protected function updateToCommit(PackageInterface $package, string $path, string $reference, string $prettyVersion): ?string { - $force = !empty($this->hasDiscardedChanges[$path]) || !empty($this->hasStashedChanges[$path]) ? '-f ' : ''; + $force = !empty($this->hasDiscardedChanges[$path]) || !empty($this->hasStashedChanges[$path]) ? ['-f'] : []; // This uses the "--" sequence to separate branch from file parameters. // // Otherwise git tries the branch name as well as file name. // If the non-existent branch is actually the name of a file, the file // is checked out. - $template = 'git checkout '.$force.'%s -- && git reset --hard %1$s --'; + $branch = Preg::replace('{(?:^dev-|(?:\.x)?-dev$)}i', '', $prettyVersion); + /** + * @var \Closure(non-empty-list): bool $execute + * @phpstan-ignore varTag.nativeType + */ + $execute = function (array $command) use (&$output, $path) { + /** @var non-empty-list $command */ + $output = ''; + + return 0 === $this->process->execute($command, $output, $path); + }; + $branches = null; - if (0 === $this->process->execute('git branch -r', $output, $path)) { + if ($execute(['git', 'branch', '-r'])) { $branches = $output; } @@ -462,8 +466,10 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface && null !== $branches && Preg::isMatch('{^\s+composer/'.preg_quote($reference).'$}m', $branches) ) { - $command = sprintf('git checkout '.$force.'-B %s %s -- && git reset --hard %2$s --', ProcessExecutor::escape($branch), ProcessExecutor::escape('composer/'.$reference)); - if (0 === $this->process->execute($command, $output, $path)) { + $command1 = array_merge(['git', 'checkout'], $force, ['-B', $branch, 'composer/'.$reference, '--']); + $command2 = ['git', 'reset', '--hard', 'composer/'.$reference, '--']; + + if ($execute($command1) && $execute($command2)) { return null; } } @@ -475,17 +481,18 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface $branch = 'v' . $branch; } - $command = sprintf('git checkout %s --', ProcessExecutor::escape($branch)); - $fallbackCommand = sprintf('git checkout '.$force.'-B %s %s --', ProcessExecutor::escape($branch), ProcessExecutor::escape('composer/'.$branch)); - $resetCommand = sprintf('git reset --hard %s --', ProcessExecutor::escape($reference)); + $command = ['git', 'checkout', $branch, '--']; + $fallbackCommand = array_merge(['git', 'checkout'], $force, ['-B', $branch, 'composer/'.$branch, '--']); + $resetCommand = ['git', 'reset', '--hard', $reference, '--']; - if (0 === $this->process->execute("($command || $fallbackCommand) && $resetCommand", $output, $path)) { + if (($execute($command) || $execute($fallbackCommand)) && $execute($resetCommand)) { return null; } } - $command = sprintf($template, ProcessExecutor::escape($gitRef)); - if (0 === $this->process->execute($command, $output, $path)) { + $command1 = array_merge(['git', 'checkout'], $force, [$gitRef, '--']); + $command2 = ['git', 'reset', '--hard', $gitRef, '--']; + if ($execute($command1) && $execute($command2)) { return null; } @@ -497,12 +504,14 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface $exceptionExtra = "\nIt looks like the commit hash is not available in the repository, maybe ".($package->isDev() ? 'the commit was removed from the branch' : 'the tag was recreated').'? Run "composer update '.$package->getPrettyName().'" to resolve this.'; } + $command = implode(' ', $command1). ' && '.implode(' ', $command2); + throw new \RuntimeException(Url::sanitize('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput() . $exceptionExtra)); } protected function updateOriginUrl(string $path, string $url): void { - $this->process->execute(sprintf('git remote set-url origin -- %s', ProcessExecutor::escape($url)), $output, $path); + $this->process->execute(['git', 'remote', 'set-url', 'origin', '--', $url], $output, $path); $this->setPushUrl($path, $url); } @@ -515,7 +524,7 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface if (!in_array('ssh', $protocols, true)) { $pushUrl = 'https://' . $match[1] . '/'.$match[2].'/'.$match[3].'.git'; } - $cmd = sprintf('git remote set-url --push origin -- %s', ProcessExecutor::escape($pushUrl)); + $cmd = ['git', 'remote', 'set-url', '--push', 'origin', '--', $pushUrl]; $this->process->execute($cmd, $ignoredOutput, $path); } } @@ -526,10 +535,10 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface protected function getCommitLogs(string $fromReference, string $toReference, string $path): string { $path = $this->normalizePath($path); - $command = sprintf('git log %s..%s --pretty=format:"%%h - %%an: %%s"'.GitUtil::getNoShowSignatureFlag($this->process), ProcessExecutor::escape($fromReference), ProcessExecutor::escape($toReference)); + $command = array_merge(['git', 'log', $fromReference.'..'.$toReference, '--pretty=format:%h - %an: %s'], GitUtil::getNoShowSignatureFlags($this->process)); if (0 !== $this->process->execute($command, $output, $path)) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); } return $output; @@ -542,7 +551,10 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface protected function discardChanges(string $path): PromiseInterface { $path = $this->normalizePath($path); - if (0 !== $this->process->execute('git clean -df && git reset --hard', $output, $path)) { + if (0 !== $this->process->execute(['git', 'clean', '-df'], $output, $path)) { + throw new \RuntimeException("Could not reset changes\n\n:".$output); + } + if (0 !== $this->process->execute(['git', 'reset', '--hard'], $output, $path)) { throw new \RuntimeException("Could not reset changes\n\n:".$output); } @@ -558,7 +570,7 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface protected function stashChanges(string $path): PromiseInterface { $path = $this->normalizePath($path); - if (0 !== $this->process->execute('git stash --include-untracked', $output, $path)) { + if (0 !== $this->process->execute(['git', 'stash', '--include-untracked'], $output, $path)) { throw new \RuntimeException("Could not stash changes\n\n:".$output); } @@ -573,7 +585,7 @@ class GitDownloader extends VcsDownloader implements DvcsDownloaderInterface protected function viewDiff(string $path): void { $path = $this->normalizePath($path); - if (0 !== $this->process->execute('git diff HEAD', $output, $path)) { + if (0 !== $this->process->execute(['git', 'diff', 'HEAD'], $output, $path)) { throw new \RuntimeException("Could not view diff\n\n:".$output); } diff --git a/src/Composer/Downloader/GzipDownloader.php b/src/Composer/Downloader/GzipDownloader.php index 9037f5226..010219822 100644 --- a/src/Composer/Downloader/GzipDownloader.php +++ b/src/Composer/Downloader/GzipDownloader.php @@ -31,7 +31,7 @@ class GzipDownloader extends ArchiveDownloader // Try to use gunzip on *nix if (!Platform::isWindows()) { - $command = 'gzip -cd -- ' . ProcessExecutor::escape($file) . ' > ' . ProcessExecutor::escape($targetFilepath); + $command = ['sh', '-c', 'gzip -cd -- "$0" > "$1"', $file, $targetFilepath]; if (0 === $this->process->execute($command, $ignoredOutput)) { return \React\Promise\resolve(null); @@ -44,7 +44,7 @@ class GzipDownloader extends ArchiveDownloader return \React\Promise\resolve(null); } - $processError = 'Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput(); + $processError = 'Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput(); throw new \RuntimeException($processError); } diff --git a/src/Composer/Downloader/HgDownloader.php b/src/Composer/Downloader/HgDownloader.php index b0cc9cd7d..4709ae35e 100644 --- a/src/Composer/Downloader/HgDownloader.php +++ b/src/Composer/Downloader/HgDownloader.php @@ -41,16 +41,15 @@ class HgDownloader extends VcsDownloader { $hgUtils = new HgUtils($this->io, $this->config, $this->process); - $cloneCommand = static function (string $url) use ($path): string { - return sprintf('hg clone -- %s %s', ProcessExecutor::escape($url), ProcessExecutor::escape($path)); + $cloneCommand = static function (string $url) use ($path): array { + return ['hg', 'clone', '--', $url, $path]; }; $hgUtils->runCommand($cloneCommand, $url, $path); - $ref = ProcessExecutor::escape($package->getSourceReference()); - $command = sprintf('hg up -- %s', $ref); + $command = ['hg', 'up', '--', (string) $package->getSourceReference()]; if (0 !== $this->process->execute($command, $ignoredOutput, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); } return \React\Promise\resolve(null); @@ -70,10 +69,14 @@ class HgDownloader extends VcsDownloader throw new \RuntimeException('The .hg directory is missing from '.$path.', see https://getcomposer.org/commit-deps for more information'); } - $command = static function ($url) use ($ref): string { - return sprintf('hg pull -- %s && hg up -- %s', ProcessExecutor::escape($url), ProcessExecutor::escape($ref)); + $command = static function ($url): array { + return ['hg', 'pull', '--', $url]; }; + $hgUtils->runCommand($command, $url, $path); + $command = static function () use ($ref): array { + return ['hg', 'up', '--', $ref]; + }; $hgUtils->runCommand($command, $url, $path); return \React\Promise\resolve(null); @@ -88,7 +91,7 @@ class HgDownloader extends VcsDownloader return null; } - $this->process->execute('hg st', $output, realpath($path)); + $this->process->execute(['hg', 'st'], $output, realpath($path)); $output = trim($output); @@ -100,10 +103,10 @@ class HgDownloader extends VcsDownloader */ protected function getCommitLogs(string $fromReference, string $toReference, string $path): string { - $command = sprintf('hg log -r %s:%s --style compact', ProcessExecutor::escape($fromReference), ProcessExecutor::escape($toReference)); + $command = ['hg', 'log', '-r', $fromReference.':'.$toReference, '--style', 'compact']; if (0 !== $this->process->execute($command, $output, realpath($path))) { - throw new \RuntimeException('Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput()); + throw new \RuntimeException('Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput()); } return $output; diff --git a/src/Composer/Downloader/RarDownloader.php b/src/Composer/Downloader/RarDownloader.php index a4142c655..04e16eaff 100644 --- a/src/Composer/Downloader/RarDownloader.php +++ b/src/Composer/Downloader/RarDownloader.php @@ -34,13 +34,13 @@ class RarDownloader extends ArchiveDownloader // Try to use unrar on *nix if (!Platform::isWindows()) { - $command = 'unrar x -- ' . ProcessExecutor::escape($file) . ' ' . ProcessExecutor::escape($path) . ' >/dev/null && chmod -R u+w ' . ProcessExecutor::escape($path); + $command = ['sh', '-c', 'unrar x -- "$0" "$1" >/dev/null && chmod -R u+w "$1"', $file, $path]; if (0 === $this->process->execute($command, $ignoredOutput)) { return \React\Promise\resolve(null); } - $processError = 'Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput(); + $processError = 'Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput(); } if (!class_exists('RarArchive')) { diff --git a/src/Composer/Downloader/SvnDownloader.php b/src/Composer/Downloader/SvnDownloader.php index be180d63d..ca88ea839 100644 --- a/src/Composer/Downloader/SvnDownloader.php +++ b/src/Composer/Downloader/SvnDownloader.php @@ -59,7 +59,7 @@ class SvnDownloader extends VcsDownloader } $this->io->writeError(" Checking out ".$package->getSourceReference()); - $this->execute($package, $url, "svn co", sprintf("%s/%s", $url, $ref), null, $path); + $this->execute($package, $url, ['svn', 'co'], sprintf("%s/%s", $url, $ref), null, $path); return \React\Promise\resolve(null); } @@ -77,13 +77,13 @@ class SvnDownloader extends VcsDownloader } $util = new SvnUtil($url, $this->io, $this->config, $this->process); - $flags = ""; + $flags = []; if (version_compare($util->binaryVersion(), '1.7.0', '>=')) { - $flags .= ' --ignore-ancestry'; + $flags[] = '--ignore-ancestry'; } $this->io->writeError(" Checking out " . $ref); - $this->execute($target, $url, "svn switch" . $flags, sprintf("%s/%s", $url, $ref), $path); + $this->execute($target, $url, array_merge(['svn', 'switch'], $flags), sprintf("%s/%s", $url, $ref), $path); return \React\Promise\resolve(null); } @@ -97,7 +97,7 @@ class SvnDownloader extends VcsDownloader return null; } - $this->process->execute('svn status --ignore-externals', $output, $path); + $this->process->execute(['svn', 'status', '--ignore-externals'], $output, $path); return Preg::isMatch('{^ *[^X ] +}m', $output) ? $output : null; } @@ -107,13 +107,13 @@ class SvnDownloader extends VcsDownloader * if necessary. * * @param string $baseUrl Base URL of the repository - * @param string $command SVN command to run + * @param non-empty-list $command SVN command to run * @param string $url SVN url * @param string $cwd Working directory * @param string $path Target for a checkout * @throws \RuntimeException */ - protected function execute(PackageInterface $package, string $baseUrl, string $command, string $url, ?string $cwd = null, ?string $path = null): string + protected function execute(PackageInterface $package, string $baseUrl, array $command, string $url, ?string $cwd = null, ?string $path = null): string { $util = new SvnUtil($baseUrl, $this->io, $this->config, $this->process); $util->setCacheCredentials($this->cacheCredentials); @@ -194,10 +194,10 @@ class SvnDownloader extends VcsDownloader { if (Preg::isMatch('{@(\d+)$}', $fromReference) && Preg::isMatch('{@(\d+)$}', $toReference)) { // retrieve the svn base url from the checkout folder - $command = sprintf('svn info --non-interactive --xml -- %s', ProcessExecutor::escape($path)); + $command = ['svn', 'info', '--non-interactive', '--xml', '--', $path]; if (0 !== $this->process->execute($command, $output, $path)) { throw new \RuntimeException( - 'Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput() + 'Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput() ); } @@ -214,7 +214,7 @@ class SvnDownloader extends VcsDownloader $fromRevision = Preg::replace('{.*@(\d+)$}', '$1', $fromReference); $toRevision = Preg::replace('{.*@(\d+)$}', '$1', $toReference); - $command = sprintf('svn log -r%s:%s --incremental', ProcessExecutor::escape($fromRevision), ProcessExecutor::escape($toRevision)); + $command = ['svn', 'log', '-r', $fromRevision.':'.$toRevision, '--incremental']; $util = new SvnUtil($baseUrl, $this->io, $this->config, $this->process); $util->setCacheCredentials($this->cacheCredentials); @@ -222,7 +222,7 @@ class SvnDownloader extends VcsDownloader return $util->executeLocal($command, $path, null, $this->io->isVerbose()); } catch (\RuntimeException $e) { throw new \RuntimeException( - 'Failed to execute ' . $command . "\n\n".$e->getMessage() + 'Failed to execute ' . implode(' ', $command) . "\n\n".$e->getMessage() ); } } @@ -235,7 +235,7 @@ class SvnDownloader extends VcsDownloader */ protected function discardChanges(string $path): PromiseInterface { - if (0 !== $this->process->execute('svn revert -R .', $output, $path)) { + if (0 !== $this->process->execute(['svn', 'revert', '-R', '.'], $output, $path)) { throw new \RuntimeException("Could not reset changes\n\n:".$this->process->getErrorOutput()); } diff --git a/src/Composer/Downloader/XzDownloader.php b/src/Composer/Downloader/XzDownloader.php index 8c44cd199..286d32cff 100644 --- a/src/Composer/Downloader/XzDownloader.php +++ b/src/Composer/Downloader/XzDownloader.php @@ -26,13 +26,13 @@ class XzDownloader extends ArchiveDownloader { protected function extract(PackageInterface $package, string $file, string $path): PromiseInterface { - $command = 'tar -xJf ' . ProcessExecutor::escape($file) . ' -C ' . ProcessExecutor::escape($path); + $command = ['tar', '-xJf', $file, '-C', $path]; if (0 === $this->process->execute($command, $ignoredOutput)) { return \React\Promise\resolve(null); } - $processError = 'Failed to execute ' . $command . "\n\n" . $this->process->getErrorOutput(); + $processError = 'Failed to execute ' . implode(' ', $command) . "\n\n" . $this->process->getErrorOutput(); throw new \RuntimeException($processError); } diff --git a/src/Composer/Downloader/ZipDownloader.php b/src/Composer/Downloader/ZipDownloader.php index 1904668be..54d23a5c6 100644 --- a/src/Composer/Downloader/ZipDownloader.php +++ b/src/Composer/Downloader/ZipDownloader.php @@ -27,7 +27,7 @@ use ZipArchive; */ class ZipDownloader extends ArchiveDownloader { - /** @var array */ + /** @var array> */ private static $unzipCommands; /** @var bool */ private static $hasZipArchive; @@ -46,16 +46,16 @@ class ZipDownloader extends ArchiveDownloader self::$unzipCommands = []; $finder = new ExecutableFinder; if (Platform::isWindows() && ($cmd = $finder->find('7z', null, ['C:\Program Files\7-Zip']))) { - self::$unzipCommands[] = ['7z', ProcessExecutor::escape($cmd).' x -bb0 -y %s -o%s']; + self::$unzipCommands[] = ['7z', $cmd, 'x', '-bb0', '-y', '%file%', '-o%path%']; } if ($cmd = $finder->find('unzip')) { - self::$unzipCommands[] = ['unzip', ProcessExecutor::escape($cmd).' -qq %s -d %s']; + self::$unzipCommands[] = ['unzip', $cmd, '-qq', '%file%', '-d', '%path%']; } if (!Platform::isWindows() && ($cmd = $finder->find('7z'))) { // 7z linux/macOS support is only used if unzip is not present - self::$unzipCommands[] = ['7z', ProcessExecutor::escape($cmd).' x -bb0 -y %s -o%s']; + self::$unzipCommands[] = ['7z', $cmd, 'x', '-bb0', '-y', '%file%', '-o%path%']; } if (!Platform::isWindows() && ($cmd = $finder->find('7zz'))) { // 7zz linux/macOS support is only used if unzip is not present - self::$unzipCommands[] = ['7zz', ProcessExecutor::escape($cmd).' x -bb0 -y %s -o%s']; + self::$unzipCommands[] = ['7zz', $cmd, 'x', '-bb0', '-y', '%file%', '-o%path%']; } } @@ -114,24 +114,28 @@ class ZipDownloader extends ArchiveDownloader // Force Exception throwing if the other alternative extraction method is not available $isLastChance = !self::$hasZipArchive; - if (!self::$unzipCommands) { + if (0 === \count(self::$unzipCommands)) { // This was call as the favorite extract way, but is not available // We switch to the alternative return $this->extractWithZipArchive($package, $file, $path); } $commandSpec = reset(self::$unzipCommands); - $command = sprintf($commandSpec[1], ProcessExecutor::escape($file), ProcessExecutor::escape($path)); - // normalize separators to backslashes to avoid problems with 7-zip on windows - // see https://github.com/composer/composer/issues/10058 - if (Platform::isWindows()) { - $command = sprintf($commandSpec[1], ProcessExecutor::escape(strtr($file, '/', '\\')), ProcessExecutor::escape(strtr($path, '/', '\\'))); - } - $executable = $commandSpec[0]; + $command = array_slice($commandSpec, 1); + $map = [ + // normalize separators to backslashes to avoid problems with 7-zip on windows + // see https://github.com/composer/composer/issues/10058 + '%file%' => strtr($file, '/', DIRECTORY_SEPARATOR), + '%path%' => strtr($path, '/', DIRECTORY_SEPARATOR), + ]; + $command = array_map(static function ($value) use ($map) { + return strtr($value, $map); + }, $command); + if (!$warned7ZipLinux && !Platform::isWindows() && in_array($executable, ['7z', '7zz'], true)) { $warned7ZipLinux = true; - if (0 === $this->process->execute($executable, $output)) { + if (0 === $this->process->execute([$commandSpec[1]], $output)) { if (Preg::isMatchStrictGroups('{^\s*7-Zip(?: \[64\])? ([0-9.]+)}', $output, $match) && version_compare($match[1], '21.01', '<')) { $this->io->writeError(' Unzipping using '.$executable.' '.$match[1].' may result in incorrect file permissions. Install '.$executable.' 21.01+ or unzip to ensure you get correct permissions.'); } @@ -186,7 +190,7 @@ class ZipDownloader extends ArchiveDownloader $output = $process->getErrorOutput(); $output = str_replace(', '.$file.'.zip or '.$file.'.ZIP', '', $output); - return $tryFallback(new \RuntimeException('Failed to extract '.$package->getName().': ('.$process->getExitCode().') '.$command."\n\n".$output)); + return $tryFallback(new \RuntimeException('Failed to extract '.$package->getName().': ('.$process->getExitCode().') '.implode(' ', $command)."\n\n".$output)); } }); } catch (\Throwable $e) { diff --git a/src/Composer/Package/Locker.php b/src/Composer/Package/Locker.php index 160031a30..38cd8ef3a 100644 --- a/src/Composer/Package/Locker.php +++ b/src/Composer/Package/Locker.php @@ -554,13 +554,14 @@ class Locker case 'git': GitUtil::cleanEnv(); - if (0 === $this->process->execute('git log -n1 --pretty=%ct '.ProcessExecutor::escape($sourceRef).GitUtil::getNoShowSignatureFlag($this->process), $output, $path) && Preg::isMatch('{^\s*\d+\s*$}', $output)) { + $command = array_merge(['git', 'log', '-n1', '--pretty=%ct', (string) $sourceRef], GitUtil::getNoShowSignatureFlags($this->process)); + if (0 === $this->process->execute($command, $output, $path) && Preg::isMatch('{^\s*\d+\s*$}', $output)) { $datetime = new \DateTime('@'.trim($output), new \DateTimeZone('UTC')); } break; case 'hg': - if (0 === $this->process->execute('hg log --template "{date|hgdate}" -r '.ProcessExecutor::escape($sourceRef), $output, $path) && Preg::isMatch('{^\s*(\d+)\s*}', $output, $match)) { + if (0 === $this->process->execute(['hg', 'log', '--template', '{date|hgdate}', '-r', (string) $sourceRef], $output, $path) && Preg::isMatch('{^\s*(\d+)\s*}', $output, $match)) { $datetime = new \DateTime('@'.$match[1], new \DateTimeZone('UTC')); } break; diff --git a/src/Composer/Package/Version/VersionGuesser.php b/src/Composer/Package/Version/VersionGuesser.php index 8a0fe9c14..7e0f8278e 100644 --- a/src/Composer/Package/Version/VersionGuesser.php +++ b/src/Composer/Package/Version/VersionGuesser.php @@ -198,7 +198,7 @@ class VersionGuesser } if (null === $commit) { - $command = 'git log --pretty="%H" -n1 HEAD'.GitUtil::getNoShowSignatureFlag($this->process); + $command = array_merge(['git', 'log', '--pretty=%H', '-n1', 'HEAD'], GitUtil::getNoShowSignatureFlags($this->process)); if (0 === $this->process->execute($command, $output, $path)) { $commit = trim($output) ?: null; } @@ -217,7 +217,7 @@ class VersionGuesser private function versionFromGitTags(string $path): ?array { // try to fetch current version from git tags - if (0 === $this->process->execute('git describe --exact-match --tags', $output, $path)) { + if (0 === $this->process->execute(['git', 'describe', '--exact-match', '--tags'], $output, $path)) { try { $version = $this->versionParser->normalize(trim($output)); @@ -237,7 +237,7 @@ class VersionGuesser private function guessHgVersion(array $packageConfig, string $path): ?array { // try to fetch current version from hg branch - if (0 === $this->process->execute('hg branch', $output, $path)) { + if (0 === $this->process->execute(['hg', 'branch'], $output, $path)) { $branch = trim($output); $version = $this->versionParser->normalizeBranch($branch); $isFeatureBranch = 0 === strpos($version, 'dev-'); @@ -375,14 +375,14 @@ class VersionGuesser $prettyVersion = null; // try to fetch current version from fossil - if (0 === $this->process->execute('fossil branch list', $output, $path)) { + if (0 === $this->process->execute(['fossil', 'branch', 'list'], $output, $path)) { $branch = trim($output); $version = $this->versionParser->normalizeBranch($branch); $prettyVersion = 'dev-' . $branch; } // try to fetch current version from fossil tags - if (0 === $this->process->execute('fossil tag list', $output, $path)) { + if (0 === $this->process->execute(['fossil', 'tag', 'list'], $output, $path)) { try { $version = $this->versionParser->normalize(trim($output)); $prettyVersion = trim($output); @@ -403,7 +403,7 @@ class VersionGuesser SvnUtil::cleanEnv(); // try to fetch current version from svn - if (0 === $this->process->execute('svn info --xml', $output, $path)) { + if (0 === $this->process->execute(['svn', 'info', '--xml'], $output, $path)) { $trunkPath = isset($packageConfig['trunk-path']) ? preg_quote($packageConfig['trunk-path'], '#') : 'trunk'; $branchesPath = isset($packageConfig['branches-path']) ? preg_quote($packageConfig['branches-path'], '#') : 'branches'; $tagsPath = isset($packageConfig['tags-path']) ? preg_quote($packageConfig['tags-path'], '#') : 'tags'; diff --git a/src/Composer/Platform/HhvmDetector.php b/src/Composer/Platform/HhvmDetector.php index 46886c353..284b0ba8a 100644 --- a/src/Composer/Platform/HhvmDetector.php +++ b/src/Composer/Platform/HhvmDetector.php @@ -49,11 +49,7 @@ class HhvmDetector $hhvmPath = $this->executableFinder->find('hhvm'); if ($hhvmPath !== null) { $this->processExecutor = $this->processExecutor ?? new ProcessExecutor(); - $exitCode = $this->processExecutor->execute( - ProcessExecutor::escape($hhvmPath). - ' --php -d hhvm.jit=0 -r "echo HHVM_VERSION;" 2>/dev/null', - self::$hhvmVersion - ); + $exitCode = $this->processExecutor->execute([$hhvmPath, '--php', '-d', 'hhvm.jit=0', '-r', 'echo HHVM_VERSION;'], self::$hhvmVersion); if ($exitCode !== 0) { self::$hhvmVersion = false; } diff --git a/src/Composer/Repository/PathRepository.php b/src/Composer/Repository/PathRepository.php index 4e99bd415..0b8d99236 100644 --- a/src/Composer/Repository/PathRepository.php +++ b/src/Composer/Repository/PathRepository.php @@ -194,8 +194,8 @@ class PathRepository extends ArrayRepository implements ConfigurableRepositoryIn // carry over the root package version if this path repo is in the same git repository as root package if (!isset($package['version']) && ($rootVersion = Platform::getEnv('COMPOSER_ROOT_VERSION'))) { if ( - 0 === $this->process->execute('git rev-parse HEAD', $ref1, $path) - && 0 === $this->process->execute('git rev-parse HEAD', $ref2) + 0 === $this->process->execute(['git', 'rev-parse', 'HEAD'], $ref1, $path) + && 0 === $this->process->execute(['git', 'rev-parse', 'HEAD'], $ref2) && $ref1 === $ref2 ) { $package['version'] = $this->versionGuesser->getRootVersionFromEnv(); @@ -203,7 +203,7 @@ class PathRepository extends ArrayRepository implements ConfigurableRepositoryIn } $output = ''; - if ('auto' === $reference && is_dir($path . DIRECTORY_SEPARATOR . '.git') && 0 === $this->process->execute('git log -n1 --pretty=%H'.GitUtil::getNoShowSignatureFlag($this->process), $output, $path)) { + if ('auto' === $reference && is_dir($path . DIRECTORY_SEPARATOR . '.git') && 0 === $this->process->execute(array_merge(['git', 'log', '-n1', '--pretty=%H'], GitUtil::getNoShowSignatureFlags($this->process)), $output, $path)) { $package['dist']['reference'] = trim($output); } diff --git a/src/Composer/Repository/Vcs/FossilDriver.php b/src/Composer/Repository/Vcs/FossilDriver.php index e55b0d3ac..8a305216c 100644 --- a/src/Composer/Repository/Vcs/FossilDriver.php +++ b/src/Composer/Repository/Vcs/FossilDriver.php @@ -71,7 +71,7 @@ class FossilDriver extends VcsDriver */ protected function checkFossil(): void { - if (0 !== $this->process->execute('fossil version', $ignoredOutput)) { + if (0 !== $this->process->execute(['fossil', 'version'], $ignoredOutput)) { throw new \RuntimeException("fossil was not found, check that it is installed and in your PATH env.\n\n" . $this->process->getErrorOutput()); } } @@ -81,6 +81,8 @@ class FossilDriver extends VcsDriver */ protected function updateLocalRepo(): void { + assert($this->repoFile !== null); + $fs = new Filesystem(); $fs->ensureDirectoryExists($this->checkoutDir); @@ -89,8 +91,8 @@ class FossilDriver extends VcsDriver } // update the repo if it is a valid fossil repository - if (is_file($this->repoFile) && is_dir($this->checkoutDir) && 0 === $this->process->execute('fossil info', $output, $this->checkoutDir)) { - if (0 !== $this->process->execute('fossil pull', $output, $this->checkoutDir)) { + if (is_file($this->repoFile) && is_dir($this->checkoutDir) && 0 === $this->process->execute(['fossil', 'info'], $output, $this->checkoutDir)) { + if (0 !== $this->process->execute(['fossil', 'pull'], $output, $this->checkoutDir)) { $this->io->writeError('Failed to update '.$this->url.', package information from this repository may be outdated ('.$this->process->getErrorOutput().')'); } } else { @@ -100,13 +102,13 @@ class FossilDriver extends VcsDriver $fs->ensureDirectoryExists($this->checkoutDir); - if (0 !== $this->process->execute(sprintf('fossil clone -- %s %s', ProcessExecutor::escape($this->url), ProcessExecutor::escape($this->repoFile)), $output)) { + if (0 !== $this->process->execute(['fossil', 'clone', '--', $this->url, $this->repoFile], $output)) { $output = $this->process->getErrorOutput(); throw new \RuntimeException('Failed to clone '.$this->url.' to repository ' . $this->repoFile . "\n\n" .$output); } - if (0 !== $this->process->execute(sprintf('fossil open --nested -- %s', ProcessExecutor::escape($this->repoFile)), $output, $this->checkoutDir)) { + if (0 !== $this->process->execute(['fossil', 'open', '--nested', '--', $this->repoFile], $output, $this->checkoutDir)) { $output = $this->process->getErrorOutput(); throw new \RuntimeException('Failed to open repository '.$this->repoFile.' in ' . $this->checkoutDir . "\n\n" .$output); @@ -155,10 +157,9 @@ class FossilDriver extends VcsDriver */ public function getFileContent(string $file, string $identifier): ?string { - $command = sprintf('fossil cat -r %s -- %s', ProcessExecutor::escape($identifier), ProcessExecutor::escape($file)); - $this->process->execute($command, $content, $this->checkoutDir); + $this->process->execute(['fossil', 'cat', '-r', $identifier, '--', $file], $content, $this->checkoutDir); - if (!trim($content)) { + if ('' === trim($content)) { return null; } @@ -170,7 +171,7 @@ class FossilDriver extends VcsDriver */ public function getChangeDate(string $identifier): ?\DateTimeImmutable { - $this->process->execute('fossil finfo -b -n 1 composer.json', $output, $this->checkoutDir); + $this->process->execute(['fossil', 'finfo', '-b', '-n', '1', 'composer.json'], $output, $this->checkoutDir); [, $date] = explode(' ', trim($output), 3); return new \DateTimeImmutable($date, new \DateTimeZone('UTC')); @@ -184,7 +185,7 @@ class FossilDriver extends VcsDriver if (null === $this->tags) { $tags = []; - $this->process->execute('fossil tag list', $output, $this->checkoutDir); + $this->process->execute(['fossil', 'tag', 'list'], $output, $this->checkoutDir); foreach ($this->process->splitLines($output) as $tag) { $tags[$tag] = $tag; } @@ -203,7 +204,7 @@ class FossilDriver extends VcsDriver if (null === $this->branches) { $branches = []; - $this->process->execute('fossil branch list', $output, $this->checkoutDir); + $this->process->execute(['fossil', 'branch', 'list'], $output, $this->checkoutDir); foreach ($this->process->splitLines($output) as $branch) { $branch = trim(Preg::replace('/^\*/', '', trim($branch))); $branches[$branch] = $branch; @@ -237,7 +238,7 @@ class FossilDriver extends VcsDriver $process = new ProcessExecutor($io); // check whether there is a fossil repo in that path - if ($process->execute('fossil info', $output, $url) === 0) { + if ($process->execute(['fossil', 'info'], $output, $url) === 0) { return true; } } diff --git a/src/Composer/Repository/Vcs/GitDriver.php b/src/Composer/Repository/Vcs/GitDriver.php index 57bc9b2ef..7be1faba0 100644 --- a/src/Composer/Repository/Vcs/GitDriver.php +++ b/src/Composer/Repository/Vcs/GitDriver.php @@ -102,7 +102,7 @@ class GitDriver extends VcsDriver } // select currently checked out branch as default branch - $this->process->execute('git branch --no-color', $output, $this->repoDir); + $this->process->execute(['git', 'branch', '--no-color'], $output, $this->repoDir); $branches = $this->process->splitLines($output); if (!in_array('* master', $branches)) { foreach ($branches as $branch) { @@ -150,8 +150,7 @@ class GitDriver extends VcsDriver throw new \RuntimeException('Invalid git identifier detected. Identifier must not start with a -, given: ' . $identifier); } - $resource = sprintf('%s:%s', ProcessExecutor::escape($identifier), ProcessExecutor::escape($file)); - $this->process->execute(sprintf('git show %s', $resource), $content, $this->repoDir); + $this->process->execute(['git', 'show', $identifier.':'.$file], $content, $this->repoDir); if (trim($content) === '') { return null; @@ -165,10 +164,7 @@ class GitDriver extends VcsDriver */ public function getChangeDate(string $identifier): ?\DateTimeImmutable { - $this->process->execute(sprintf( - 'git -c log.showSignature=false log -1 --format=%%at %s', - ProcessExecutor::escape($identifier) - ), $output, $this->repoDir); + $this->process->execute(['git', '-c', 'log.showSignature=false', 'log', '-1', '--format=%at', $identifier], $output, $this->repoDir); return new \DateTimeImmutable('@'.trim($output), new \DateTimeZone('UTC')); } @@ -181,7 +177,7 @@ class GitDriver extends VcsDriver if (null === $this->tags) { $this->tags = []; - $this->process->execute('git show-ref --tags --dereference', $output, $this->repoDir); + $this->process->execute(['git', 'show-ref', '--tags', '--dereference'], $output, $this->repoDir); foreach ($this->process->splitLines($output) as $tag) { if ($tag !== '' && Preg::isMatch('{^([a-f0-9]{40}) refs/tags/(\S+?)(\^\{\})?$}', $tag, $match)) { $this->tags[$match[2]] = $match[1]; @@ -200,7 +196,7 @@ class GitDriver extends VcsDriver if (null === $this->branches) { $branches = []; - $this->process->execute('git branch --no-color --no-abbrev -v', $output, $this->repoDir); + $this->process->execute(['git', 'branch', '--no-color', '--no-abbrev', '-v'], $output, $this->repoDir); foreach ($this->process->splitLines($output) as $branch) { if ($branch !== '' && !Preg::isMatch('{^ *[^/]+/HEAD }', $branch)) { if (Preg::isMatchStrictGroups('{^(?:\* )? *(\S+) *([a-f0-9]+)(?: .*)?$}', $branch, $match) && $match[1][0] !== '-') { @@ -233,7 +229,7 @@ class GitDriver extends VcsDriver $process = new ProcessExecutor($io); // check whether there is a git repo in that path - if ($process->execute('git tag', $output, $url) === 0) { + if ($process->execute(['git', 'tag'], $output, $url) === 0) { return true; } GitUtil::checkForRepoOwnershipError($process->getErrorOutput(), $url); @@ -247,9 +243,7 @@ class GitDriver extends VcsDriver GitUtil::cleanEnv(); try { - $gitUtil->runCommand(static function ($url): string { - return 'git ls-remote --heads -- ' . ProcessExecutor::escape($url); - }, $url, sys_get_temp_dir()); + $gitUtil->runCommands([['git', 'ls-remote', '--heads', '--', '%url%']], $url, sys_get_temp_dir()); } catch (\RuntimeException $e) { return false; } diff --git a/src/Composer/Repository/Vcs/HgDriver.php b/src/Composer/Repository/Vcs/HgDriver.php index e468ca746..625a2a1eb 100644 --- a/src/Composer/Repository/Vcs/HgDriver.php +++ b/src/Composer/Repository/Vcs/HgDriver.php @@ -63,8 +63,8 @@ class HgDriver extends VcsDriver $hgUtils = new HgUtils($this->io, $this->config, $this->process); // update the repo if it is a valid hg repository - if (is_dir($this->repoDir) && 0 === $this->process->execute('hg summary', $output, $this->repoDir)) { - if (0 !== $this->process->execute('hg pull', $output, $this->repoDir)) { + if (is_dir($this->repoDir) && 0 === $this->process->execute(['hg', 'summary'], $output, $this->repoDir)) { + if (0 !== $this->process->execute(['hg', 'pull'], $output, $this->repoDir)) { $this->io->writeError('Failed to update '.$this->url.', package information from this repository may be outdated ('.$this->process->getErrorOutput().')'); } } else { @@ -72,8 +72,8 @@ class HgDriver extends VcsDriver $fs->removeDirectory($this->repoDir); $repoDir = $this->repoDir; - $command = static function ($url) use ($repoDir): string { - return sprintf('hg clone --noupdate -- %s %s', ProcessExecutor::escape($url), ProcessExecutor::escape($repoDir)); + $command = static function ($url) use ($repoDir): array { + return ['hg', 'clone', '--noupdate', '--', $url, $repoDir]; }; $hgUtils->runCommand($command, $this->url, null); @@ -90,7 +90,7 @@ class HgDriver extends VcsDriver public function getRootIdentifier(): string { if (null === $this->rootIdentifier) { - $this->process->execute('hg tip --template "{node}"', $output, $this->repoDir); + $this->process->execute(['hg', 'tip', '--template', '{node}'], $output, $this->repoDir); $output = $this->process->splitLines($output); $this->rootIdentifier = $output[0]; } @@ -131,7 +131,7 @@ class HgDriver extends VcsDriver throw new \RuntimeException('Invalid hg identifier detected. Identifier must not start with a -, given: ' . $identifier); } - $resource = sprintf('hg cat -r %s -- %s', ProcessExecutor::escape($identifier), ProcessExecutor::escape($file)); + $resource = ['hg', 'cat', '-r', $identifier, '--', $file]; $this->process->execute($resource, $content, $this->repoDir); if (!trim($content)) { @@ -147,10 +147,7 @@ class HgDriver extends VcsDriver public function getChangeDate(string $identifier): ?\DateTimeImmutable { $this->process->execute( - sprintf( - 'hg log --template "{date|rfc3339date}" -r %s', - ProcessExecutor::escape($identifier) - ), + ['hg', 'log', '--template', '{date|rfc3339date}', '-r', $identifier], $output, $this->repoDir ); @@ -166,7 +163,7 @@ class HgDriver extends VcsDriver if (null === $this->tags) { $tags = []; - $this->process->execute('hg tags', $output, $this->repoDir); + $this->process->execute(['hg', 'tags'], $output, $this->repoDir); foreach ($this->process->splitLines($output) as $tag) { if ($tag && Preg::isMatchStrictGroups('(^([^\s]+)\s+\d+:(.*)$)', $tag, $match)) { $tags[$match[1]] = $match[2]; @@ -189,14 +186,14 @@ class HgDriver extends VcsDriver $branches = []; $bookmarks = []; - $this->process->execute('hg branches', $output, $this->repoDir); + $this->process->execute(['hg', 'branches'], $output, $this->repoDir); foreach ($this->process->splitLines($output) as $branch) { if ($branch && Preg::isMatchStrictGroups('(^([^\s]+)\s+\d+:([a-f0-9]+))', $branch, $match) && $match[1][0] !== '-') { $branches[$match[1]] = $match[2]; } } - $this->process->execute('hg bookmarks', $output, $this->repoDir); + $this->process->execute(['hg', 'bookmarks'], $output, $this->repoDir); foreach ($this->process->splitLines($output) as $branch) { if ($branch && Preg::isMatchStrictGroups('(^(?:[\s*]*)([^\s]+)\s+\d+:(.*)$)', $branch, $match) && $match[1][0] !== '-') { $bookmarks[$match[1]] = $match[2]; @@ -228,7 +225,7 @@ class HgDriver extends VcsDriver $process = new ProcessExecutor($io); // check whether there is a hg repo in that path - if ($process->execute('hg summary', $output, $url) === 0) { + if ($process->execute(['hg', 'summary'], $output, $url) === 0) { return true; } } @@ -238,7 +235,7 @@ class HgDriver extends VcsDriver } $process = new ProcessExecutor($io); - $exit = $process->execute(sprintf('hg identify -- %s', ProcessExecutor::escape($url)), $ignored); + $exit = $process->execute(['hg', 'identify', '--', $url], $ignored); return $exit === 0; } diff --git a/src/Composer/Repository/Vcs/SvnDriver.php b/src/Composer/Repository/Vcs/SvnDriver.php index d4f318079..9a303ee14 100644 --- a/src/Composer/Repository/Vcs/SvnDriver.php +++ b/src/Composer/Repository/Vcs/SvnDriver.php @@ -187,7 +187,7 @@ class SvnDriver extends VcsDriver try { $resource = $path.$file; - $output = $this->execute('svn cat', $this->baseUrl . $resource . $rev); + $output = $this->execute(['svn', 'cat'], $this->baseUrl . $resource . $rev); if ('' === trim($output)) { return null; } @@ -213,7 +213,7 @@ class SvnDriver extends VcsDriver $rev = ''; } - $output = $this->execute('svn info', $this->baseUrl . $path . $rev); + $output = $this->execute(['svn', 'info'], $this->baseUrl . $path . $rev); foreach ($this->process->splitLines($output) as $line) { if ($line !== '' && Preg::isMatchStrictGroups('{^Last Changed Date: ([^(]+)}', $line, $match)) { return new \DateTimeImmutable($match[1], new \DateTimeZone('UTC')); @@ -232,7 +232,7 @@ class SvnDriver extends VcsDriver $tags = []; if ($this->tagsPath !== false) { - $output = $this->execute('svn ls --verbose', $this->baseUrl . '/' . $this->tagsPath); + $output = $this->execute(['svn', 'ls', '--verbose'], $this->baseUrl . '/' . $this->tagsPath); if ($output !== '') { $lastRev = 0; foreach ($this->process->splitLines($output) as $line) { @@ -271,7 +271,7 @@ class SvnDriver extends VcsDriver $trunkParent = $this->baseUrl . '/' . $this->trunkPath; } - $output = $this->execute('svn ls --verbose', $trunkParent); + $output = $this->execute(['svn', 'ls', '--verbose'], $trunkParent); if ($output !== '') { foreach ($this->process->splitLines($output) as $line) { $line = trim($line); @@ -290,7 +290,7 @@ class SvnDriver extends VcsDriver unset($output); if ($this->branchesPath !== false) { - $output = $this->execute('svn ls --verbose', $this->baseUrl . '/' . $this->branchesPath); + $output = $this->execute(['svn', 'ls', '--verbose'], $this->baseUrl . '/' . $this->branchesPath); if ($output !== '') { $lastRev = 0; foreach ($this->process->splitLines(trim($output)) as $line) { @@ -331,10 +331,7 @@ class SvnDriver extends VcsDriver } $process = new ProcessExecutor($io); - $exit = $process->execute( - "svn info --non-interactive -- ".ProcessExecutor::escape($url), - $ignoredOutput - ); + $exit = $process->execute(['svn', 'info', '--non-interactive', '--', $url], $ignoredOutput); if ($exit === 0) { // This is definitely a Subversion repository. @@ -375,11 +372,11 @@ class SvnDriver extends VcsDriver * Execute an SVN command and try to fix up the process with credentials * if necessary. * - * @param string $command The svn command to run. + * @param non-empty-list $command The svn command to run. * @param string $url The SVN URL. * @throws \RuntimeException */ - protected function execute(string $command, string $url): string + protected function execute(array $command, string $url): string { if (null === $this->util) { $this->util = new SvnUtil($this->baseUrl, $this->io, $this->config, $this->process); diff --git a/src/Composer/Util/Bitbucket.php b/src/Composer/Util/Bitbucket.php index 2be4a815c..15743a7e3 100644 --- a/src/Composer/Util/Bitbucket.php +++ b/src/Composer/Util/Bitbucket.php @@ -77,7 +77,7 @@ class Bitbucket } // if available use token from git config - if (0 === $this->process->execute('git config bitbucket.accesstoken', $output)) { + if (0 === $this->process->execute(['git', 'config', 'bitbucket.accesstoken'], $output)) { $this->io->setAuthentication($originUrl, 'x-token-auth', trim($output)); return true; diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php index 099747add..57e4fd6f3 100644 --- a/src/Composer/Util/Filesystem.php +++ b/src/Composer/Util/Filesystem.php @@ -109,9 +109,9 @@ class Filesystem } if (Platform::isWindows()) { - $cmd = sprintf('rmdir /S /Q %s', ProcessExecutor::escape(realpath($directory))); + $cmd = ['rmdir', '/S', '/Q', Platform::realpath($directory)]; } else { - $cmd = sprintf('rm -rf %s', ProcessExecutor::escape($directory)); + $cmd = ['rm', '-rf', $directory]; } $result = $this->getProcess()->execute($cmd, $output) === 0; @@ -144,9 +144,9 @@ class Filesystem } if (Platform::isWindows()) { - $cmd = sprintf('rmdir /S /Q %s', ProcessExecutor::escape(realpath($directory))); + $cmd = ['rmdir', '/S', '/Q', Platform::realpath($directory)]; } else { - $cmd = sprintf('rm -rf %s', ProcessExecutor::escape($directory)); + $cmd = ['rm', '-rf', $directory]; } $promise = $this->getProcess()->executeAsync($cmd); @@ -427,8 +427,7 @@ class Filesystem if (Platform::isWindows()) { // Try to copy & delete - this is a workaround for random "Access denied" errors. - $command = sprintf('xcopy %s %s /E /I /Q /Y', ProcessExecutor::escape($source), ProcessExecutor::escape($target)); - $result = $this->getProcess()->execute($command, $output); + $result = $this->getProcess()->execute(['xcopy', $source, $target, '/E', '/I', '/Q', '/Y'], $output); // clear stat cache because external processes aren't tracked by the php stat cache clearstatcache(); @@ -441,8 +440,7 @@ class Filesystem } else { // We do not use PHP's "rename" function here since it does not support // the case where $source, and $target are located on different partitions. - $command = sprintf('mv %s %s', ProcessExecutor::escape($source), ProcessExecutor::escape($target)); - $result = $this->getProcess()->execute($command, $output); + $result = $this->getProcess()->execute(['mv', $source, $target], $output); // clear stat cache because external processes aren't tracked by the php stat cache clearstatcache(); @@ -841,11 +839,7 @@ class Filesystem @rmdir($junction); } - $cmd = sprintf( - 'mklink /J %s %s', - ProcessExecutor::escape(str_replace('/', DIRECTORY_SEPARATOR, $junction)), - ProcessExecutor::escape(realpath($target)) - ); + $cmd = ['mklink', '/J', str_replace('/', DIRECTORY_SEPARATOR, $junction), Platform::realpath($target)]; if ($this->getProcess()->execute($cmd, $output) !== 0) { throw new IOException(sprintf('Failed to create junction to "%s" at "%s".', $target, $junction), 0, null, $target); } diff --git a/src/Composer/Util/Git.php b/src/Composer/Util/Git.php index f3369c8aa..e340c1b46 100644 --- a/src/Composer/Util/Git.php +++ b/src/Composer/Util/Git.php @@ -63,26 +63,90 @@ class Git } /** + * Runs a set of commands using the $url or a variation of it (with auth, ssh, ..) + * + * Commands should use %url% placeholders for the URL instead of inlining it to allow this function to do its job + * %sanitizedUrl% is also automatically replaced by the url without user/pass + * + * As soon as a single command fails it will halt, so assume the commands are run as && in bash + * + * @param non-empty-array> $commands + * @param mixed $commandOutput the output will be written into this var if passed by ref + * if a callable is passed it will be used as output handler + */ + public function runCommands(array $commands, string $url, ?string $cwd, bool $initialClone = false, &$commandOutput = null): void + { + $callables = []; + foreach ($commands as $cmd) { + $callables[] = static function (string $url) use ($cmd): array { + $map = [ + '%url%' => $url, + '%sanitizedUrl%' => Preg::replace('{://([^@]+?):(.+?)@}', '://', $url), + ]; + + return array_map(static function ($value) use ($map): string { + return $map[$value] ?? $value; + }, $cmd); + }; + } + + // @phpstan-ignore method.deprecated + $this->runCommand($callables, $url, $cwd, $initialClone, $commandOutput); + } + + /** + * @param callable|array $commandCallable * @param mixed $commandOutput the output will be written into this var if passed by ref * if a callable is passed it will be used as output handler + * @deprecated Use runCommands with placeholders instead of callbacks for simplicity */ - public function runCommand(callable $commandCallable, string $url, ?string $cwd, bool $initialClone = false, &$commandOutput = null): void + public function runCommand($commandCallable, string $url, ?string $cwd, bool $initialClone = false, &$commandOutput = null): void { + $commandCallables = is_callable($commandCallable) ? [$commandCallable] : $commandCallable; + $lastCommand = ''; + // Ensure we are allowed to use this URL by config $this->config->prohibitUrlByConfig($url, $this->io); if ($initialClone) { $origCwd = $cwd; - $cwd = null; } + $runCommands = function ($url) use ($commandCallables, $cwd, &$commandOutput, &$lastCommand, $initialClone) { + $collectOutputs = !is_callable($commandOutput); + $outputs = []; + + $status = 0; + $counter = 0; + foreach ($commandCallables as $callable) { + $lastCommand = $callable($url); + if ($collectOutputs) { + $outputs[] = ''; + $output = &$outputs[count($outputs) - 1]; + } else { + $output = &$commandOutput; + } + $status = $this->process->execute($lastCommand, $output, $initialClone && $counter === 0 ? null : $cwd); + if ($status !== 0) { + break; + } + $counter++; + } + + if ($collectOutputs) { + $commandOutput = implode('', $outputs); + } + + return $status; + }; + if (Preg::isMatch('{^ssh://[^@]+@[^:]+:[^0-9]+}', $url)) { throw new \InvalidArgumentException('The source URL ' . $url . ' is invalid, ssh URLs should have a port number after ":".' . "\n" . 'Use ssh://git@example.com:22/path or just git@example.com:path if you do not want to provide a password or custom port.'); } if (!$initialClone) { // capture username/password from URL if there is one and we have no auth configured yet - $this->process->execute('git remote -v', $output, $cwd); + $this->process->execute(['git', 'remote', '-v'], $output, $cwd); if (Preg::isMatchStrictGroups('{^(?:composer|origin)\s+https?://(.+):(.+)@([^/]+)}im', $output, $match) && !$this->io->hasAuthentication($match[3])) { $this->io->setAuthentication($match[3], rawurldecode($match[1]), rawurldecode($match[2])); } @@ -100,7 +164,7 @@ class Git $protoUrl = $protocol . "://" . $match[1] . "/" . $match[2]; } - if (0 === $this->process->execute($commandCallable($protoUrl), $commandOutput, $cwd)) { + if (0 === $runCommands($protoUrl)) { return; } $messages[] = '- ' . $protoUrl . "\n" . Preg::replace('#^#m', ' ', $this->process->getErrorOutput()); @@ -119,11 +183,9 @@ class Git // if we have a private github url and the ssh protocol is disabled then we skip it and directly fallback to https $bypassSshForGitHub = Preg::isMatch('{^git@' . self::getGitHubDomainsRegex($this->config) . ':(.+?)\.git$}i', $url) && !in_array('ssh', $protocols, true); - $command = $commandCallable($url); - $auth = null; $credentials = []; - if ($bypassSshForGitHub || 0 !== $this->process->execute($command, $commandOutput, $cwd)) { + if ($bypassSshForGitHub || 0 !== $runCommands($url)) { $errorMsg = $this->process->getErrorOutput(); // private github repository without ssh key access, try https with auth // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups @@ -143,8 +205,7 @@ class Git if ($this->io->hasAuthentication($match[1])) { $auth = $this->io->getAuthentication($match[1]); $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $match[1] . '/' . $match[2] . '.git'; - $command = $commandCallable($authUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + if (0 === $runCommands($authUrl)) { return; } @@ -180,8 +241,7 @@ class Git $auth = $this->io->getAuthentication($domain); $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $domain . '/' . $repo_with_git_part; - $command = $commandCallable($authUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + if (0 === $runCommands($authUrl)) { // Well if that succeeded on our first try, let's just // take the win. return; @@ -199,8 +259,7 @@ class Git if ($this->io->hasAuthentication($domain)) { $auth = $this->io->getAuthentication($domain); $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $domain . '/' . $repo_with_git_part; - $command = $commandCallable($authUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + if (0 === $runCommands($authUrl)) { return; } @@ -209,8 +268,7 @@ class Git //Falling back to ssh $sshUrl = 'git@bitbucket.org:' . $repo_with_git_part; $this->io->writeError(' No bitbucket authentication configured. Falling back to ssh.'); - $command = $commandCallable($sshUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + if (0 === $runCommands($sshUrl)) { return; } @@ -242,8 +300,7 @@ class Git $authUrl = $match[1] . '://' . rawurlencode((string) $auth['username']) . ':' . rawurlencode((string) $auth['password']) . '@' . $match[2] . '/' . $match[3]; } - $command = $commandCallable($authUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + if (0 === $runCommands($authUrl)) { return; } @@ -280,8 +337,7 @@ class Git if (null !== $auth) { $authUrl = $match[1] . rawurlencode((string) $auth['username']) . ':' . rawurlencode((string) $auth['password']) . '@' . $match[2] . $match[3]; - $command = $commandCallable($authUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + if (0 === $runCommands($authUrl)) { $this->io->setAuthentication($match[2], $auth['username'], $auth['password']); $authHelper = new AuthHelper($this->io, $this->config); $authHelper->storeAuth($match[2], $storeAuth); @@ -298,11 +354,12 @@ class Git $this->filesystem->removeDirectory($origCwd); } + $lastCommand = implode(' ', $lastCommand); if (count($credentials) > 0) { - $command = $this->maskCredentials($command, $credentials); + $lastCommand = $this->maskCredentials($lastCommand, $credentials); $errorMsg = $this->maskCredentials($errorMsg, $credentials); } - $this->throwException('Failed to execute ' . $command . "\n\n" . $errorMsg, $url); + $this->throwException('Failed to execute ' . $lastCommand . "\n\n" . $errorMsg, $url); } } @@ -315,14 +372,16 @@ class Git } // update the repo if it is a valid git repository - if (is_dir($dir) && 0 === $this->process->execute('git rev-parse --git-dir', $output, $dir) && trim($output) === '.') { + if (is_dir($dir) && 0 === $this->process->execute(['git', 'rev-parse', '--git-dir'], $output, $dir) && trim($output) === '.') { try { - $commandCallable = static function ($url): string { - $sanitizedUrl = Preg::replace('{://([^@]+?):(.+?)@}', '://', $url); + $commands = [ + ['git', 'remote', 'set-url', 'origin', '--', '%url%'], + ['git', 'remote', 'update', '--prune', 'origin'], + ['git', 'remote', 'set-url', 'origin', '--', '%sanitizedUrl%'], + ['git', 'gc', '--auto'], + ]; - return sprintf('git remote set-url origin -- %s && git remote update --prune origin && git remote set-url origin -- %s && git gc --auto', ProcessExecutor::escape($url), ProcessExecutor::escape($sanitizedUrl)); - }; - $this->runCommand($commandCallable, $url, $dir); + $this->runCommands($commands, $url, $dir); } catch (\Exception $e) { $this->io->writeError('Sync mirror failed: ' . $e->getMessage() . '', true, IOInterface::DEBUG); @@ -336,11 +395,7 @@ class Git // clean up directory and do a fresh clone into it $this->filesystem->removeDirectory($dir); - $commandCallable = static function ($url) use ($dir): string { - return sprintf('git clone --mirror -- %s %s', ProcessExecutor::escape($url), ProcessExecutor::escape($dir)); - }; - - $this->runCommand($commandCallable, $url, $dir, true); + $this->runCommands([['git', 'clone', '--mirror', '--', '%url%', $dir]], $url, $dir, true); return true; } @@ -352,10 +407,10 @@ class Git $branch = Preg::replace('{(?:^dev-|(?:\.x)?-dev$)}i', '', $prettyVersion); $branches = null; $tags = null; - if (0 === $this->process->execute('git branch', $output, $dir)) { + if (0 === $this->process->execute(['git', 'branch'], $output, $dir)) { $branches = $output; } - if (0 === $this->process->execute('git tag', $output, $dir)) { + if (0 === $this->process->execute(['git', 'tag'], $output, $dir)) { $tags = $output; } @@ -390,11 +445,23 @@ class Git return ''; } + /** + * @return list + */ + public static function getNoShowSignatureFlags(ProcessExecutor $process): array + { + $flags = static::getNoShowSignatureFlag($process); + if ('' === $flags) { + return []; + } + + return explode(' ', substr($flags, 1)); + } + private function checkRefIsInMirror(string $dir, string $ref): bool { - if (is_dir($dir) && 0 === $this->process->execute('git rev-parse --git-dir', $output, $dir) && trim($output) === '.') { - $escapedRef = ProcessExecutor::escape($ref.'^{commit}'); - $exitCode = $this->process->execute(sprintf('git rev-parse --quiet --verify %s', $escapedRef), $ignoredOutput, $dir); + if (is_dir($dir) && 0 === $this->process->execute(['git', 'rev-parse', '--git-dir'], $output, $dir) && trim($output) === '.') { + $exitCode = $this->process->execute(['git', 'rev-parse', '--quiet', '--verify', $ref.'^{commit}'], $ignoredOutput, $dir); if ($exitCode === 0) { return true; } @@ -439,15 +506,15 @@ class Git try { if ($isLocalPathRepository) { - $this->process->execute('git remote show origin', $output, $dir); + $this->process->execute(['git', 'remote', 'show', 'origin'], $output, $dir); } else { - $commandCallable = static function ($url): string { - $sanitizedUrl = Preg::replace('{://([^@]+?):(.+?)@}', '://', $url); + $commands = [ + ['git', 'remote', 'set-url', 'origin', '--', '%url%'], + ['git', 'remote', 'show', 'origin'], + ['git', 'remote', 'set-url', 'origin', '--', '%sanitizedUrl%'], + ]; - return sprintf('git remote set-url origin -- %s && git remote show origin && git remote set-url origin -- %s', ProcessExecutor::escape($url), ProcessExecutor::escape($sanitizedUrl)); - }; - - $this->runCommand($commandCallable, $url, $dir, false, $output); + $this->runCommands($commands, $url, $dir, false, $output); } $lines = $this->process->splitLines($output); @@ -513,7 +580,7 @@ class Git // git might delete a directory when it fails and php will not know clearstatcache(); - if (0 !== $this->process->execute('git --version', $ignoredOutput)) { + if (0 !== $this->process->execute(['git', '--version'], $ignoredOutput)) { throw new \RuntimeException(Url::sanitize('Failed to clone ' . $url . ', git was not found, check that it is installed and in your PATH env.' . "\n\n" . $this->process->getErrorOutput())); } @@ -529,7 +596,7 @@ class Git { if (false === self::$version) { self::$version = null; - if (0 === $process->execute('git --version', $output) && Preg::isMatch('/^git version (\d+(?:\.\d+)+)/m', $output, $matches)) { + if (0 === $process->execute(['git', '--version'], $output) && Preg::isMatch('/^git version (\d+(?:\.\d+)+)/m', $output, $matches)) { self::$version = $matches[1]; } } diff --git a/src/Composer/Util/GitHub.php b/src/Composer/Util/GitHub.php index 3574e5183..64ee4f559 100644 --- a/src/Composer/Util/GitHub.php +++ b/src/Composer/Util/GitHub.php @@ -61,7 +61,7 @@ class GitHub } // if available use token from git config - if (0 === $this->process->execute('git config github.accesstoken', $output)) { + if (0 === $this->process->execute(['git', 'config', 'github.accesstoken'], $output)) { $this->io->setAuthentication($originUrl, trim($output), 'x-oauth-basic'); return true; @@ -86,7 +86,7 @@ class GitHub } $note = 'Composer'; - if ($this->config->get('github-expose-hostname') === true && 0 === $this->process->execute('hostname', $output)) { + if ($this->config->get('github-expose-hostname') === true && 0 === $this->process->execute(['hostname'], $output)) { $note .= ' on ' . trim($output); } $note .= ' ' . date('Y-m-d Hi'); diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php index e5985c2db..35d8e0ded 100644 --- a/src/Composer/Util/GitLab.php +++ b/src/Composer/Util/GitLab.php @@ -65,14 +65,14 @@ class GitLab } // if available use token from git config - if (0 === $this->process->execute('git config gitlab.accesstoken', $output)) { + if (0 === $this->process->execute(['git', 'config', 'gitlab.accesstoken'], $output)) { $this->io->setAuthentication($originUrl, trim($output), 'oauth2'); return true; } // if available use deploy token from git config - if (0 === $this->process->execute('git config gitlab.deploytoken.user', $tokenUser) && 0 === $this->process->execute('git config gitlab.deploytoken.token', $tokenPassword)) { + if (0 === $this->process->execute(['git', 'config', 'gitlab.deploytoken.user'], $tokenUser) && 0 === $this->process->execute(['git', 'config', 'gitlab.deploytoken.token'], $tokenPassword)) { $this->io->setAuthentication($originUrl, trim($tokenUser), trim($tokenPassword)); return true; diff --git a/src/Composer/Util/Hg.php b/src/Composer/Util/Hg.php index 28107584b..34b4796fa 100644 --- a/src/Composer/Util/Hg.php +++ b/src/Composer/Util/Hg.php @@ -111,7 +111,7 @@ class Hg { if (false === self::$version) { self::$version = null; - if (0 === $process->execute('hg --version', $output) && Preg::isMatch('/^.+? (\d+(?:\.\d+)+)(?:\+.*?)?\)?\r?\n/', $output, $matches)) { + if (0 === $process->execute(['hg', '--version'], $output) && Preg::isMatch('/^.+? (\d+(?:\.\d+)+)(?:\+.*?)?\)?\r?\n/', $output, $matches)) { self::$version = $matches[1]; } } diff --git a/src/Composer/Util/Perforce.php b/src/Composer/Util/Perforce.php index ff931158c..bfed834b1 100644 --- a/src/Composer/Util/Perforce.php +++ b/src/Composer/Util/Perforce.php @@ -14,6 +14,7 @@ namespace Composer\Util; use Composer\IO\IOInterface; use Composer\Pcre\Preg; +use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; /** @@ -81,7 +82,7 @@ class Perforce public static function checkServerExists(string $url, ProcessExecutor $processExecutor): bool { - return 0 === $processExecutor->execute('p4 -p ' . ProcessExecutor::escape($url) . ' info -s', $ignoredOutput); + return 0 === $processExecutor->execute(['p4', '-p', $url, 'info', '-s'], $ignoredOutput); } /** @@ -248,7 +249,7 @@ class Perforce } $this->p4User = $this->io->ask('Enter P4 User:'); if ($this->windowsFlag) { - $command = 'p4 set P4USER=' . $this->p4User; + $command = $this->getP4Executable().' set P4USER=' . $this->p4User; } else { $command = 'export P4USER=' . $this->p4User; } @@ -261,7 +262,7 @@ class Perforce protected function getP4variable(string $name): ?string { if ($this->windowsFlag) { - $command = 'p4 set'; + $command = $this->getP4Executable().' set'; $this->executeCommand($command); $result = trim($this->commandResult); $resArray = explode(PHP_EOL, $result); @@ -309,7 +310,7 @@ class Perforce */ public function generateP4Command(string $command, bool $useClient = true): string { - $p4Command = 'p4 '; + $p4Command = $this->getP4Executable().' '; $p4Command .= '-u ' . $this->getUser() . ' '; if ($useClient) { $p4Command .= '-c ' . $this->getClient() . ' '; @@ -620,4 +621,17 @@ class Perforce { $this->filesystem = $fs; } + + private function getP4Executable(): string + { + static $p4Executable; + + if ($p4Executable) { + return $p4Executable; + } + + $finder = new ExecutableFinder(); + + return $p4Executable = $finder->find('p4') ?? 'p4'; + } } diff --git a/src/Composer/Util/Platform.php b/src/Composer/Util/Platform.php index b13fe5bb8..dcbfaa1c5 100644 --- a/src/Composer/Util/Platform.php +++ b/src/Composer/Util/Platform.php @@ -54,6 +54,19 @@ class Platform return $cwd; } + /** + * Infallible realpath version that falls back on the given $path if realpath is not working + */ + public static function realpath(string $path): string + { + $realPath = realpath($path); + if ($realPath === false) { + return $path; + } + + return $realPath; + } + /** * getenv() equivalent but reads from the runtime global variables first * @@ -308,7 +321,7 @@ class Platform if (defined('PHP_OS_FAMILY') && PHP_OS_FAMILY === 'Linux') { $process = new ProcessExecutor(); try { - if (0 === $process->execute('lsmod | grep vboxguest', $ignoredOutput)) { + if (0 === $process->execute(['lsmod'], $output) && str_contains($output, 'vboxguest')) { return self::$isVirtualBoxGuest = true; } } catch (\Exception $e) { diff --git a/src/Composer/Util/ProcessExecutor.php b/src/Composer/Util/ProcessExecutor.php index bf4b49846..dc773beb9 100644 --- a/src/Composer/Util/ProcessExecutor.php +++ b/src/Composer/Util/ProcessExecutor.php @@ -20,6 +20,7 @@ use Symfony\Component\Process\Process; use Symfony\Component\Process\Exception\RuntimeException; use React\Promise\Promise; use React\Promise\PromiseInterface; +use Symfony\Component\Process\ExecutableFinder; /** * @author Robert Schönthal @@ -33,6 +34,14 @@ class ProcessExecutor private const STATUS_FAILED = 4; private const STATUS_ABORTED = 5; + private const BUILTIN_CMD_COMMANDS = [ + 'assoc', 'break', 'call', 'cd', 'chdir', 'cls', 'color', 'copy', 'date', + 'del', 'dir', 'echo', 'endlocal', 'erase', 'exit', 'for', 'ftype', 'goto', + 'help', 'if', 'label', 'md', 'mkdir', 'mklink', 'move', 'path', 'pause', + 'popd', 'prompt', 'pushd', 'rd', 'rem', 'ren', 'rename', 'rmdir', 'set', + 'setlocal', 'shift', 'start', 'time', 'title', 'type', 'ver', 'vol', + ]; + private const GIT_CMDS_NEED_GIT_DIR = [ ['show'], ['log'], @@ -63,6 +72,9 @@ class ProcessExecutor /** @var bool */ private $allowAsync = false; + /** @var array */ + private static $executables = []; + public function __construct(?IOInterface $io = null) { $this->io = $io; @@ -71,7 +83,7 @@ class ProcessExecutor /** * runs a process on the commandline * - * @param string|list $command the command to execute + * @param string|non-empty-list $command the command to execute * @param mixed $output the output will be written into this var if passed by ref * if a callable is passed it will be used as output handler * @param null|string $cwd the working directory @@ -89,7 +101,7 @@ class ProcessExecutor /** * runs a process on the commandline in TTY mode * - * @param string|list $command the command to execute + * @param string|non-empty-list $command the command to execute * @param null|string $cwd the working directory * @return int statuscode */ @@ -103,15 +115,26 @@ class ProcessExecutor } /** - * @param string|list $command + * @param string|non-empty-list $command * @param array|null $env * @param mixed $output */ private function runProcess($command, ?string $cwd, ?array $env, bool $tty, &$output = null): ?int { + // On Windows, we don't rely on the OS to find the executable if possible to avoid lookups + // in the current directory which could be untrusted. Instead we use the ExecutableFinder. + if (is_string($command)) { + if (Platform::isWindows() && Preg::isMatch('{^([^:/\\\\]++) }', $command, $match)) { + $command = substr_replace($command, self::escape(self::getExecutable($match[1])), 0, strlen($match[1])); + } + $process = Process::fromShellCommandline($command, $cwd, $env, null, static::getTimeout()); } else { + if (Platform::isWindows() && \strlen($command[0]) === strcspn($command[0], ':/\\')) { + $command[0] = self::getExecutable($command[0]); + } + $process = new Process($command, $cwd, $env, null, static::getTimeout()); } @@ -161,7 +184,7 @@ class ProcessExecutor } /** - * @param string|list $command + * @param string|non-empty-list $command * @param mixed $output */ private function doExecute($command, ?string $cwd, bool $tty, &$output = null): int @@ -178,7 +201,7 @@ class ProcessExecutor $isBareRepository = !is_dir(sprintf('%s/.git', rtrim($cwd, '/'))); if ($isBareRepository) { $configValue = ''; - $this->runProcess('git config safe.bareRepository', $cwd, ['GIT_DIR' => $cwd], $tty, $configValue); + $this->runProcess(['git', 'config', 'safe.bareRepository'], $cwd, ['GIT_DIR' => $cwd], $tty, $configValue); $configValue = trim($configValue); if ($configValue === 'explicit') { $env = ['GIT_DIR' => $cwd]; @@ -550,4 +573,23 @@ class ProcessExecutor return false; } + + /** + * Resolves executable paths on Windows + */ + private static function getExecutable(string $name): string + { + if (\in_array(strtolower($name), self::BUILTIN_CMD_COMMANDS, true)) { + return $name; + } + + if (!isset(self::$executables[$name])) { + $path = (new ExecutableFinder())->find($name, $name); + if ($path !== null) { + self::$executables[$name] = $path; + } + } + + return self::$executables[$name] ?? $name; + } } diff --git a/src/Composer/Util/Svn.php b/src/Composer/Util/Svn.php index ea7d5dbeb..506a14ec7 100644 --- a/src/Composer/Util/Svn.php +++ b/src/Composer/Util/Svn.php @@ -90,7 +90,7 @@ class Svn * Execute an SVN remote command and try to fix up the process with credentials * if necessary. * - * @param string $command SVN command to run + * @param non-empty-list $command SVN command to run * @param string $url SVN url * @param ?string $cwd Working directory * @param ?string $path Target for a checkout @@ -98,7 +98,7 @@ class Svn * * @throws \RuntimeException */ - public function execute(string $command, string $url, ?string $cwd = null, ?string $path = null, bool $verbose = false): string + public function execute(array $command, string $url, ?string $cwd = null, ?string $path = null, bool $verbose = false): string { // Ensure we are allowed to use this URL by config $this->config->prohibitUrlByConfig($url, $this->io); @@ -110,20 +110,23 @@ class Svn * Execute an SVN local command and try to fix up the process with credentials * if necessary. * - * @param string $command SVN command to run + * @param non-empty-list $command SVN command to run * @param string $path Path argument passed thru to the command * @param string $cwd Working directory * @param bool $verbose Output all output to the user * * @throws \RuntimeException */ - public function executeLocal(string $command, string $path, ?string $cwd = null, bool $verbose = false): string + public function executeLocal(array $command, string $path, ?string $cwd = null, bool $verbose = false): string { // A local command has no remote url return $this->executeWithAuthRetry($command, $cwd, '', $path, $verbose); } - private function executeWithAuthRetry(string $svnCommand, ?string $cwd, string $url, ?string $path, bool $verbose): ?string + /** + * @param non-empty-list $svnCommand + */ + private function executeWithAuthRetry(array $svnCommand, ?string $cwd, string $url, ?string $path, bool $verbose): ?string { // Regenerate the command at each try, to use the newly user-provided credentials $command = $this->getCommand($svnCommand, $url, $path); @@ -209,22 +212,23 @@ class Svn /** * A method to create the svn commands run. * - * @param string $cmd Usually 'svn ls' or something like that. + * @param non-empty-list $cmd Usually 'svn ls' or something like that. * @param string $url Repo URL. * @param string $path Target for a checkout + * + * @return non-empty-list */ - protected function getCommand(string $cmd, string $url, ?string $path = null): string + protected function getCommand(array $cmd, string $url, ?string $path = null): array { - $cmd = sprintf( - '%s %s%s -- %s', + $cmd = array_merge( $cmd, - '--non-interactive ', - $this->getCredentialString(), - ProcessExecutor::escape($url) + ['--non-interactive'], + $this->getCredentialArgs(), + ['--', $url] ); - if ($path) { - $cmd .= ' ' . ProcessExecutor::escape($path); + if ($path !== null) { + $cmd[] = $path; } return $cmd; @@ -234,18 +238,18 @@ class Svn * Return the credential string for the svn command. * * Adds --no-auth-cache when credentials are present. + * + * @return list */ - protected function getCredentialString(): string + protected function getCredentialArgs(): array { if (!$this->hasAuth()) { - return ''; + return []; } - return sprintf( - ' %s--username %s --password %s ', - $this->getAuthCache(), - ProcessExecutor::escape($this->getUsername()), - ProcessExecutor::escape($this->getPassword()) + return array_merge( + $this->getAuthCacheArgs(), + ['--username', $this->getUsername(), '--password', $this->getPassword()] ); } @@ -295,10 +299,12 @@ class Svn /** * Return the no-auth-cache switch. + * + * @return list */ - protected function getAuthCache(): string + protected function getAuthCacheArgs(): array { - return $this->cacheCredentials ? '' : '--no-auth-cache '; + return $this->cacheCredentials ? [] : ['--no-auth-cache']; } /** @@ -349,7 +355,7 @@ class Svn public function binaryVersion(): ?string { if (!self::$version) { - if (0 === $this->process->execute('svn --version', $output)) { + if (0 === $this->process->execute(['svn', '--version'], $output)) { if (Preg::isMatch('{(\d+(?:\.\d+)+)}', $output, $match)) { self::$version = $match[1]; } diff --git a/tests/Composer/Test/AllFunctionalTest.php b/tests/Composer/Test/AllFunctionalTest.php index 807fe5ce9..1ef22001d 100644 --- a/tests/Composer/Test/AllFunctionalTest.php +++ b/tests/Composer/Test/AllFunctionalTest.php @@ -87,6 +87,7 @@ class AllFunctionalTest extends TestCase } $proc = new Process([PHP_BINARY, '-dphar.readonly=0', './bin/compile'], $target); + $proc->setTimeout(300); $exitcode = $proc->run(); if ($exitcode !== 0 || trim($proc->getOutput()) !== '') { diff --git a/tests/Composer/Test/Downloader/FossilDownloaderTest.php b/tests/Composer/Test/Downloader/FossilDownloaderTest.php index 7ab784357..0f8103e5e 100644 --- a/tests/Composer/Test/Downloader/FossilDownloaderTest.php +++ b/tests/Composer/Test/Downloader/FossilDownloaderTest.php @@ -76,9 +76,9 @@ class FossilDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - self::getCmd('fossil clone -- \'http://fossil.kd2.org/kd2fw/\' \''.$this->workingDir.'.fossil\''), - self::getCmd('fossil open --nested -- \''.$this->workingDir.'.fossil\''), - self::getCmd('fossil update -- \'trunk\''), + ['fossil', 'clone', '--', 'http://fossil.kd2.org/kd2fw/', $this->workingDir.'.fossil'], + ['fossil', 'open', '--nested', '--', $this->workingDir.'.fossil'], + ['fossil', 'update', '--', 'trunk'], ], true); $downloader = $this->getDownloaderMock(null, null, $process); @@ -123,8 +123,9 @@ class FossilDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - self::getCmd("fossil changes"), - self::getCmd("fossil pull && fossil up 'trunk'"), + ['fossil', 'changes'], + ['fossil', 'pull'], + ['fossil', 'up', 'trunk'], ], true); $downloader = $this->getDownloaderMock(null, null, $process); @@ -143,7 +144,7 @@ class FossilDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - self::getCmd('fossil changes'), + ['fossil', 'changes'], ], true); $filesystem = $this->getMockBuilder('Composer\Util\Filesystem')->getMock(); diff --git a/tests/Composer/Test/Downloader/GitDownloaderTest.php b/tests/Composer/Test/Downloader/GitDownloaderTest.php index 50db7c73d..a46054b87 100644 --- a/tests/Composer/Test/Downloader/GitDownloaderTest.php +++ b/tests/Composer/Test/Downloader/GitDownloaderTest.php @@ -122,10 +122,16 @@ class GitDownloaderTest extends TestCase ->will($this->returnValue('dev-master')); $process = $this->getProcessExecutorMock(); + $expectedPath = Platform::isWindows() ? Platform::getCwd().'/composerPath' : 'composerPath'; $process->expects([ - $this->winCompat("git clone --no-checkout -- 'https://example.com/composer/composer' 'composerPath' && cd 'composerPath' && git remote add composer -- 'https://example.com/composer/composer' && git fetch composer && git remote set-url origin -- 'https://example.com/composer/composer' && git remote set-url composer -- 'https://example.com/composer/composer'"), - $this->winCompat("git branch -r"), - $this->winCompat("(git checkout 'master' -- || git checkout -B 'master' 'composer/master' --) && git reset --hard '1234567890123456789012345678901234567890' --"), + ['git', 'clone', '--no-checkout', '--', 'https://example.com/composer/composer', $expectedPath], + ['git', 'remote', 'add', 'composer', '--', 'https://example.com/composer/composer'], + ['git', 'fetch', 'composer'], + ['git', 'remote', 'set-url', 'origin', '--', 'https://example.com/composer/composer'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://example.com/composer/composer'], + ['git', 'branch', '-r'], + ['git', 'checkout', 'master', '--'], + ['git', 'reset', '--hard', '1234567890123456789012345678901234567890', '--'], ], true); $downloader = $this->getDownloaderMock(null, null, $process); @@ -160,16 +166,24 @@ class GitDownloaderTest extends TestCase $filesystem = new \Composer\Util\Filesystem; $filesystem->removeDirectory($cachePath); + $expectedPath = Platform::isWindows() ? Platform::getCwd().'/composerPath' : 'composerPath'; $process = $this->getProcessExecutorMock(); $process->expects([ - ['cmd' => $this->winCompat(sprintf("git clone --mirror -- 'https://example.com/composer/composer' '%s'", $cachePath)), 'callback' => static function () use ($cachePath): void { - @mkdir($cachePath, 0777, true); - }], - ['cmd' => 'git rev-parse --git-dir', 'stdout' => '.'], - $this->winCompat('git rev-parse --quiet --verify \'1234567890123456789012345678901234567890^{commit}\''), - $this->winCompat(sprintf("git clone --no-checkout '%1\$s' 'composerPath' --dissociate --reference '%1\$s' && cd 'composerPath' && git remote set-url origin -- 'https://example.com/composer/composer' && git remote add composer -- 'https://example.com/composer/composer'", $cachePath)), - 'git branch -r', - $this->winCompat("(git checkout 'master' -- || git checkout -B 'master' 'composer/master' --) && git reset --hard '1234567890123456789012345678901234567890' --"), + [ + 'cmd' => ['git', 'clone', '--mirror', '--', 'https://example.com/composer/composer', $cachePath], + 'callback' => static function () use ($cachePath): void { + @mkdir($cachePath, 0777, true); + } + ], + ['cmd' => ['git', 'rev-parse', '--git-dir'], 'stdout' => '.'], + ['git', 'rev-parse', '--quiet', '--verify', '1234567890123456789012345678901234567890^{commit}'], + ['git', 'clone', '--no-checkout', $cachePath, $expectedPath, '--dissociate', '--reference', $cachePath], + ['git', 'remote', 'set-url', 'origin', '--', 'https://example.com/composer/composer'], + ['git', 'remote', 'add', 'composer', '--', 'https://example.com/composer/composer'], + ['git', 'branch', '-r'], + ['cmd' => ['git', 'checkout', 'master', '--'], 'return' => 1], + ['git', 'checkout', '-B', 'master', 'composer/master', '--'], + ['git', 'reset', '--hard', '1234567890123456789012345678901234567890', '--'], ], true); $downloader = $this->getDownloaderMock(null, $config, $process); @@ -197,17 +211,21 @@ class GitDownloaderTest extends TestCase ->will($this->returnValue('1.0.0')); $process = $this->getProcessExecutorMock(); + $expectedPath = Platform::isWindows() ? Platform::getCwd().'/composerPath' : 'composerPath'; $process->expects([ - [ - 'cmd' => $this->winCompat("git clone --no-checkout -- 'https://github.com/mirrors/composer' 'composerPath' && cd 'composerPath' && git remote add composer -- 'https://github.com/mirrors/composer' && git fetch composer && git remote set-url origin -- 'https://github.com/mirrors/composer' && git remote set-url composer -- 'https://github.com/mirrors/composer'"), - 'return' => 1, - 'stderr' => 'Error1', - ], - $this->winCompat("git clone --no-checkout -- 'git@github.com:mirrors/composer' 'composerPath' && cd 'composerPath' && git remote add composer -- 'git@github.com:mirrors/composer' && git fetch composer && git remote set-url origin -- 'git@github.com:mirrors/composer' && git remote set-url composer -- 'git@github.com:mirrors/composer'"), - $this->winCompat("git remote set-url origin -- 'https://github.com/composer/composer'"), - $this->winCompat("git remote set-url --push origin -- 'git@github.com:composer/composer.git'"), - 'git branch -r', - $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), + ['cmd' => ['git', 'clone', '--no-checkout', '--', 'https://github.com/mirrors/composer', $expectedPath], 'return' => 1, 'stderr' => 'Error1'], + + ['git', 'clone', '--no-checkout', '--', 'git@github.com:mirrors/composer', $expectedPath], + ['git', 'remote', 'add', 'composer', '--', 'git@github.com:mirrors/composer'], + ['git', 'fetch', 'composer'], + ['git', 'remote', 'set-url', 'origin', '--', 'git@github.com:mirrors/composer'], + ['git', 'remote', 'set-url', 'composer', '--', 'git@github.com:mirrors/composer'], + + ['git', 'remote', 'set-url', 'origin', '--', 'https://github.com/composer/composer'], + ['git', 'remote', 'set-url', '--push', 'origin', '--', 'git@github.com:composer/composer.git'], + ['git', 'branch', '-r'], + ['git', 'checkout', 'ref', '--'], + ['git', 'reset', '--hard', 'ref', '--'], ], true); $downloader = $this->getDownloaderMock(null, new Config(), $process); @@ -250,11 +268,18 @@ class GitDownloaderTest extends TestCase ->will($this->returnValue('1.0.0')); $process = $this->getProcessExecutorMock(); + $expectedPath = Platform::isWindows() ? Platform::getCwd().'/composerPath' : 'composerPath'; $process->expects([ - $this->winCompat("git clone --no-checkout -- '{$url}' 'composerPath' && cd 'composerPath' && git remote add composer -- '{$url}' && git fetch composer && git remote set-url origin -- '{$url}' && git remote set-url composer -- '{$url}'"), - $this->winCompat("git remote set-url --push origin -- '{$pushUrl}'"), - 'git branch -r', - $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), + ['git', 'clone', '--no-checkout', '--', $url, $expectedPath], + ['git', 'remote', 'add', 'composer', '--', $url], + ['git', 'fetch', 'composer'], + ['git', 'remote', 'set-url', 'origin', '--', $url], + ['git', 'remote', 'set-url', 'composer', '--', $url], + + ['git', 'remote', 'set-url', '--push', 'origin', '--', $pushUrl], + ['git', 'branch', '-r'], + ['git', 'checkout', 'ref', '--'], + ['git', 'reset', '--hard', 'ref', '--'], ], true); $config = new Config(); @@ -284,28 +309,21 @@ class GitDownloaderTest extends TestCase ->will($this->returnValue('1.0.0')); $process = $this->getProcessExecutorMock(); + $expectedPath = Platform::isWindows() ? Platform::getCwd().'/composerPath' : 'composerPath'; $process->expects([ [ - 'cmd' => $this->winCompat("git clone --no-checkout -- 'https://example.com/composer/composer' 'composerPath' && cd 'composerPath' && git remote add composer -- 'https://example.com/composer/composer' && git fetch composer && git remote set-url origin -- 'https://example.com/composer/composer' && git remote set-url composer -- 'https://example.com/composer/composer'"), + 'cmd' => ['git', 'clone', '--no-checkout', '--', 'https://example.com/composer/composer', $expectedPath], 'return' => 1, ], ]); - // not using PHPUnit's expected exception because Prophecy exceptions extend from RuntimeException too so it is not safe - try { - $downloader = $this->getDownloaderMock(null, null, $process); - $downloader->download($packageMock, 'composerPath'); - $downloader->prepare('install', $packageMock, 'composerPath'); - $downloader->install($packageMock, 'composerPath'); - $downloader->cleanup('install', $packageMock, 'composerPath'); - - $this->fail('This test should throw'); - } catch (\RuntimeException $e) { - if ('RuntimeException' !== get_class($e)) { - throw $e; - } - self::assertEquals('RuntimeException', get_class($e)); - } + self::expectException('RuntimeException'); + self::expectExceptionMessage('Failed to execute git clone --no-checkout -- https://example.com/composer/composer '.$expectedPath); + $downloader = $this->getDownloaderMock(null, null, $process); + $downloader->download($packageMock, 'composerPath'); + $downloader->prepare('install', $packageMock, 'composerPath'); + $downloader->install($packageMock, 'composerPath'); + $downloader->cleanup('install', $packageMock, 'composerPath'); } public function testUpdateforPackageWithoutSourceReference(): void @@ -327,8 +345,6 @@ class GitDownloaderTest extends TestCase public function testUpdate(): void { - $expectedGitUpdateCommand = $this->winCompat("(git remote set-url composer -- 'https://github.com/composer/composer' && git rev-parse --quiet --verify 'ref^{commit}' || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- 'https://github.com/composer/composer'"); - $packageMock = $this->getMockBuilder('Composer\Package\PackageInterface')->getMock(); $packageMock->expects($this->any()) ->method('getSourceReference') @@ -345,13 +361,23 @@ class GitDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - $this->winCompat('git show-ref --head -d'), - $this->winCompat('git status --porcelain --untracked-files=no'), - $this->winCompat('git remote -v'), - $expectedGitUpdateCommand, - $this->winCompat('git branch -r'), - $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), - $this->winCompat('git remote -v'), + ['git', 'show-ref', '--head', '-d'], + ['git', 'status', '--porcelain', '--untracked-files=no'], + ['cmd' => ['git', 'rev-parse', '--quiet', '--verify', 'ref^{commit}'], 'return' => 1], + + // fallback commands for the above failing + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://github.com/composer/composer'], + ['git', 'fetch', 'composer'], + ['git', 'fetch', '--tags', 'composer'], + + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://github.com/composer/composer'], + + ['git', 'branch', '-r'], + ['git', 'checkout', 'ref', '--'], + ['git', 'reset', '--hard', 'ref', '--'], + ['git', 'remote', '-v'], ], true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); @@ -364,8 +390,6 @@ class GitDownloaderTest extends TestCase public function testUpdateWithNewRepoUrl(): void { - $expectedGitUpdateCommand = $this->winCompat("(git remote set-url composer -- 'https://github.com/composer/composer' && git rev-parse --quiet --verify 'ref^{commit}' || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- 'https://github.com/composer/composer'"); - $packageMock = $this->getMockBuilder('Composer\Package\PackageInterface')->getMock(); $packageMock->expects($this->any()) ->method('getSourceReference') @@ -385,22 +409,26 @@ class GitDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - $this->winCompat("git show-ref --head -d"), - $this->winCompat("git status --porcelain --untracked-files=no"), - $this->winCompat("git remote -v"), - $this->winCompat($expectedGitUpdateCommand), - 'git branch -r', - $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), + ['git', 'show-ref', '--head', '-d'], + ['git', 'status', '--porcelain', '--untracked-files=no'], + ['cmd' => ['git', 'rev-parse', '--quiet', '--verify', 'ref^{commit}'], 'return' => 0], + + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://github.com/composer/composer'], + + ['git', 'branch', '-r'], + ['git', 'checkout', 'ref', '--'], + ['git', 'reset', '--hard', 'ref', '--'], [ - 'cmd' => $this->winCompat("git remote -v"), + 'cmd' => ['git', 'remote', '-v'], 'stdout' => 'origin https://github.com/old/url (fetch) origin https://github.com/old/url (push) composer https://github.com/old/url (fetch) composer https://github.com/old/url (push) ', ], - $this->winCompat("git remote set-url origin -- 'https://github.com/composer/composer'"), - $this->winCompat("git remote set-url --push origin -- 'git@github.com:composer/composer.git'"), + ['git', 'remote', 'set-url', 'origin', '--', 'https://github.com/composer/composer'], + ['git', 'remote', 'set-url', '--push', 'origin', '--', 'git@github.com:composer/composer.git'], ], true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); @@ -416,9 +444,6 @@ composer https://github.com/old/url (push) */ public function testUpdateThrowsRuntimeExceptionIfGitCommandFails(): void { - $expectedGitUpdateCommand = $this->winCompat("(git remote set-url composer -- 'https://github.com/composer/composer' && git rev-parse --quiet --verify 'ref^{commit}' || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- 'https://github.com/composer/composer'"); - $expectedGitUpdateCommand2 = $this->winCompat("(git remote set-url composer -- 'git@github.com:composer/composer' && git rev-parse --quiet --verify 'ref^{commit}' || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- 'git@github.com:composer/composer'"); - $packageMock = $this->getMockBuilder('Composer\Package\PackageInterface')->getMock(); $packageMock->expects($this->any()) ->method('getSourceReference') @@ -432,44 +457,38 @@ composer https://github.com/old/url (push) $process = $this->getProcessExecutorMock(); $process->expects([ - $this->winCompat('git show-ref --head -d'), - $this->winCompat('git status --porcelain --untracked-files=no'), - $this->winCompat('git remote -v'), - [ - 'cmd' => $expectedGitUpdateCommand, - 'return' => 1, - ], - [ - 'cmd' => $expectedGitUpdateCommand2, - 'return' => 1, - ], - $this->winCompat('git --version'), + ['git', 'show-ref', '--head', '-d'], + ['git', 'status', '--porcelain', '--untracked-files=no'], + + // commit not yet in so we try to fetch + ['cmd' => ['git', 'rev-parse', '--quiet', '--verify', 'ref^{commit}'], 'return' => 1], + + // fail first fetch + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://github.com/composer/composer'], + ['cmd' => ['git', 'fetch', 'composer'], 'return' => 1], + + // fail second fetch + ['git', 'remote', 'set-url', 'composer', '--', 'git@github.com:composer/composer'], + ['cmd' => ['git', 'fetch', 'composer'], 'return' => 1], + + ['git', '--version'], ], true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); - // not using PHPUnit's expected exception because Prophecy exceptions extend from RuntimeException too so it is not safe - try { - $downloader = $this->getDownloaderMock(null, new Config(), $process); - $downloader->download($packageMock, $this->workingDir, $packageMock); - $downloader->prepare('update', $packageMock, $this->workingDir, $packageMock); - $downloader->update($packageMock, $packageMock, $this->workingDir); - $downloader->cleanup('update', $packageMock, $this->workingDir, $packageMock); - - $this->fail('This test should throw'); - } catch (\RuntimeException $e) { - if ('RuntimeException' !== get_class($e)) { - throw $e; - } - self::assertEquals('RuntimeException', get_class($e)); - } + self::expectException('RuntimeException'); + self::expectExceptionMessage('Failed to clone https://github.com/composer/composer via https, ssh protocols, aborting.'); + self::expectExceptionMessageMatches('{git@github\.com:composer/composer}'); + $downloader = $this->getDownloaderMock(null, new Config(), $process); + $downloader->download($packageMock, $this->workingDir, $packageMock); + $downloader->prepare('update', $packageMock, $this->workingDir, $packageMock); + $downloader->update($packageMock, $packageMock, $this->workingDir); + $downloader->cleanup('update', $packageMock, $this->workingDir, $packageMock); } public function testUpdateDoesntThrowsRuntimeExceptionIfGitCommandFailsAtFirstButIsAbleToRecover(): void { - $expectedFirstGitUpdateCommand = $this->winCompat("(git remote set-url composer -- '".(Platform::isWindows() ? 'C:\\' : '/')."' && git rev-parse --quiet --verify 'ref^{commit}' || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- '".(Platform::isWindows() ? 'C:\\' : '/')."'"); - $expectedSecondGitUpdateCommand = $this->winCompat("(git remote set-url composer -- 'https://github.com/composer/composer' && git rev-parse --quiet --verify 'ref^{commit}' || (git fetch composer && git fetch --tags composer)) && git remote set-url composer -- 'https://github.com/composer/composer'"); - $packageMock = $this->getMockBuilder('Composer\Package\PackageInterface')->getMock(); $packageMock->expects($this->any()) ->method('getSourceReference') @@ -486,22 +505,33 @@ composer https://github.com/old/url (push) $process = $this->getProcessExecutorMock(); $process->expects([ - $this->winCompat('git show-ref --head -d'), - $this->winCompat('git status --porcelain --untracked-files=no'), - $this->winCompat('git remote -v'), - [ - 'cmd' => $expectedFirstGitUpdateCommand, - 'return' => 1, - ], - $this->winCompat('git --version'), - $this->winCompat('git remote -v'), - [ - 'cmd' => $expectedSecondGitUpdateCommand, - 'return' => 0, - ], - $this->winCompat('git branch -r'), - $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), - $this->winCompat('git remote -v'), + ['git', 'show-ref', '--head', '-d'], + ['git', 'status', '--porcelain', '--untracked-files=no'], + + // commit not yet in so we try to fetch + ['cmd' => ['git', 'rev-parse', '--quiet', '--verify', 'ref^{commit}'], 'return' => 1], + + // fail first source URL + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', Platform::isWindows() ? 'C:\\' : '/'], + ['cmd' => ['git', 'fetch', 'composer'], 'return' => 1], + ['git', '--version'], + + // commit not yet in so we try to fetch + ['cmd' => ['git', 'rev-parse', '--quiet', '--verify', 'ref^{commit}'], 'return' => 1], + + // pass second source URL + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://github.com/composer/composer'], + ['cmd' => ['git', 'fetch', 'composer'], 'return' => 0], + ['git', 'fetch', '--tags', 'composer'], + ['git', 'remote', '-v'], + ['git', 'remote', 'set-url', 'composer', '--', 'https://github.com/composer/composer'], + + ['git', 'branch', '-r'], + ['git', 'checkout', 'ref', '--'], + ['git', 'reset', '--hard', 'ref', '--'], + ['git', 'remote', '-v'], ], true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); @@ -604,13 +634,11 @@ composer https://github.com/old/url (push) public function testRemove(): void { - $expectedGitResetCommand = $this->winCompat("git status --porcelain --untracked-files=no"); - $packageMock = $this->getMockBuilder('Composer\Package\PackageInterface')->getMock(); $process = $this->getProcessExecutorMock(); $process->expects([ - 'git show-ref --head -d', - $expectedGitResetCommand, + ['git', 'show-ref', '--head', '-d'], + ['git', 'status', '--porcelain', '--untracked-files=no'], ], true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); @@ -633,16 +661,4 @@ composer https://github.com/old/url (push) self::assertEquals('source', $downloader->getInstallationSource()); } - - private function winCompat(string $cmd): string - { - if (Platform::isWindows()) { - $cmd = str_replace('cd ', 'cd /D ', $cmd); - $cmd = str_replace('composerPath', Platform::getCwd().'/composerPath', $cmd); - - return self::getCmd($cmd); - } - - return $cmd; - } } diff --git a/tests/Composer/Test/Downloader/HgDownloaderTest.php b/tests/Composer/Test/Downloader/HgDownloaderTest.php index 544d52994..378886463 100644 --- a/tests/Composer/Test/Downloader/HgDownloaderTest.php +++ b/tests/Composer/Test/Downloader/HgDownloaderTest.php @@ -76,8 +76,8 @@ class HgDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - self::getCmd('hg clone -- \'https://mercurial.dev/l3l0/composer\' \''.$this->workingDir.'\''), - self::getCmd('hg up -- \'ref\''), + ['hg', 'clone', '--', 'https://mercurial.dev/l3l0/composer', $this->workingDir], + ['hg', 'up', '--', 'ref'], ], true); $downloader = $this->getDownloaderMock(null, null, $process); @@ -117,8 +117,9 @@ class HgDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - self::getCmd('hg st'), - self::getCmd("hg pull -- 'https://github.com/l3l0/composer' && hg up -- 'ref'"), + ['hg', 'st'], + ['hg', 'pull', '--', 'https://github.com/l3l0/composer'], + ['hg', 'up', '--', 'ref'], ], true); $downloader = $this->getDownloaderMock(null, null, $process); @@ -135,7 +136,7 @@ class HgDownloaderTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - self::getCmd('hg st'), + ['hg', 'st'], ], true); $filesystem = $this->getMockBuilder('Composer\Util\Filesystem')->getMock(); diff --git a/tests/Composer/Test/Mock/ProcessExecutorMock.php b/tests/Composer/Test/Mock/ProcessExecutorMock.php index c90b6bc1d..00f094990 100644 --- a/tests/Composer/Test/Mock/ProcessExecutorMock.php +++ b/tests/Composer/Test/Mock/ProcessExecutorMock.php @@ -57,16 +57,16 @@ class ProcessExecutorMock extends ProcessExecutor } /** - * @param array, return?: int, stdout?: string, stderr?: string, callback?: callable}> $expectations + * @param array|array{cmd: string|non-empty-list, return?: int, stdout?: string, stderr?: string, callback?: callable}> $expectations * @param bool $strict set to true if you want to provide *all* expected commands, and not just a subset you are interested in testing * @param array{return: int, stdout?: string, stderr?: string} $defaultHandler default command handler for undefined commands if not in strict mode */ public function expects(array $expectations, bool $strict = false, array $defaultHandler = ['return' => 0, 'stdout' => '', 'stderr' => '']): void { - /** @var array{cmd: string|list, return: int, stdout: string, stderr: string, callback: callable|null} $default */ + /** @var array{cmd: string|non-empty-list, return: int, stdout: string, stderr: string, callback: callable|null} $default */ $default = ['cmd' => '', 'return' => 0, 'stdout' => '', 'stderr' => '', 'callback' => null]; $this->expectations = array_map(static function ($expect) use ($default): array { - if (is_string($expect)) { + if (is_string($expect) || array_is_list($expect)) { $command = $expect; $expect = $default; $expect['cmd'] = $command; diff --git a/tests/Composer/Test/Package/Version/VersionGuesserTest.php b/tests/Composer/Test/Package/Version/VersionGuesserTest.php index 833ea9535..9dc29a299 100644 --- a/tests/Composer/Test/Package/Version/VersionGuesserTest.php +++ b/tests/Composer/Test/Package/Version/VersionGuesserTest.php @@ -36,11 +36,11 @@ class VersionGuesserTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ ['cmd' => ['git', 'branch', '-a', '--no-color', '--no-abbrev', '-v'], 'return' => 128], - ['cmd' => 'git describe --exact-match --tags', 'return' => 128], - ['cmd' => 'git log --pretty="%H" -n1 HEAD'.GitUtil::getNoShowSignatureFlag($process), 'return' => 128], - ['cmd' => 'hg branch', 'return' => 0, 'stdout' => $branch], - ['cmd' => 'hg branches', 'return' => 0], - ['cmd' => 'hg bookmarks', 'return' => 0], + ['cmd' => ['git', 'describe', '--exact-match', '--tags'], 'return' => 128], + ['cmd' => array_merge(['git', 'log', '--pretty=%H', '-n1', 'HEAD'], GitUtil::getNoShowSignatureFlags($process)), 'return' => 128], + ['cmd' => ['hg', 'branch'], 'return' => 0, 'stdout' => $branch], + ['cmd' => ['hg', 'branches'], 'return' => 0], + ['cmd' => ['hg', 'bookmarks'], 'return' => 0], ], true); GitUtil::getVersion(new ProcessExecutor); @@ -201,7 +201,7 @@ class VersionGuesserTest extends TestCase 'cmd' => ['git', 'branch', '-a', '--no-color', '--no-abbrev', '-v'], 'stdout' => "* (no branch) $commitHash Commit message\n", ], - 'git describe --exact-match --tags', + ['git', 'describe', '--exact-match', '--tags'], ], true); $config = new Config; @@ -223,7 +223,7 @@ class VersionGuesserTest extends TestCase 'cmd' => ['git', 'branch', '-a', '--no-color', '--no-abbrev', '-v'], 'stdout' => "* (HEAD detached at FETCH_HEAD) $commitHash Commit message\n", ], - 'git describe --exact-match --tags', + ['git', 'describe', '--exact-match', '--tags'], ], true); $config = new Config; @@ -245,7 +245,7 @@ class VersionGuesserTest extends TestCase 'cmd' => ['git', 'branch', '-a', '--no-color', '--no-abbrev', '-v'], 'stdout' => "* (HEAD detached at 03a15d220) $commitHash Commit message\n", ], - 'git describe --exact-match --tags', + ['git', 'describe', '--exact-match', '--tags'], ], true); $config = new Config; @@ -266,7 +266,7 @@ class VersionGuesserTest extends TestCase 'stdout' => "* (HEAD detached at v2.0.5-alpha2) 433b98d4218c181bae01865901aac045585e8a1a Commit message\n", ], [ - 'cmd' => 'git describe --exact-match --tags', + 'cmd' => ['git', 'describe', '--exact-match', '--tags'], 'stdout' => "v2.0.5-alpha2", ], ], true); @@ -289,7 +289,7 @@ class VersionGuesserTest extends TestCase 'stdout' => "* (HEAD detached at 1.0.0) c006f0c12bbbf197b5c071ffb1c0e9812bb14a4d Commit message\n", ], [ - 'cmd' => 'git describe --exact-match --tags', + 'cmd' => ['git', 'describe', '--exact-match', '--tags'], 'stdout' => '1.0.0', ], ], true); diff --git a/tests/Composer/Test/Repository/Vcs/GitDriverTest.php b/tests/Composer/Test/Repository/Vcs/GitDriverTest.php index daa8b1aea..decf6cf87 100644 --- a/tests/Composer/Test/Repository/Vcs/GitDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/GitDriverTest.php @@ -72,7 +72,7 @@ GIT; $process ->expects([[ - 'cmd' => 'git branch --no-color', + 'cmd' => ['git', 'branch', '--no-color'], 'stdout' => $stdout, ]], true); @@ -102,11 +102,17 @@ GIT; $process ->expects([[ - 'cmd' => 'git remote -v', + 'cmd' => ['git', 'remote', '-v'], 'stdout' => '', ], [ - 'cmd' => Platform::isWindows() ? "git remote set-url origin -- https://example.org/acme.git && git remote show origin && git remote set-url origin -- https://example.org/acme.git" : "git remote set-url origin -- 'https://example.org/acme.git' && git remote show origin && git remote set-url origin -- 'https://example.org/acme.git'", + 'cmd' => ['git', 'remote', 'set-url', 'origin', '--', 'https://example.org/acme.git'], + 'stdout' => '', + ], [ + 'cmd' => ['git', 'remote', 'show', 'origin'], 'stdout' => $stdout, + ], [ + 'cmd' => ['git', 'remote', 'set-url', 'origin', '--', 'https://example.org/acme.git'], + 'stdout' => '', ]]); self::assertSame('main', $driver->getRootIdentifier()); @@ -130,7 +136,7 @@ GIT; $process ->expects([[ - 'cmd' => 'git branch --no-color', + 'cmd' => ['git', 'branch', '--no-color'], 'stdout' => $stdout, ]]); @@ -155,7 +161,7 @@ GIT; $process ->expects([[ - 'cmd' => 'git branch --no-color --no-abbrev -v', + 'cmd' => ['git', 'branch', '--no-color', '--no-abbrev', '-v'], 'stdout' => $stdout, ]]); diff --git a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php index f7e62ca64..9902af644 100644 --- a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php @@ -303,18 +303,18 @@ class GitHubDriverTest extends TestCase $process = $this->getProcessExecutorMock(); $process->expects([ - ['cmd' => 'git config github.accesstoken', 'return' => 1], - 'git clone --mirror -- '.ProcessExecutor::escape($repoSshUrl).' '.ProcessExecutor::escape($this->config->get('cache-vcs-dir').'/git-github.com-composer-packagist.git/'), + ['cmd' => ['git', 'config', 'github.accesstoken'], 'return' => 1], + ['git', 'clone', '--mirror', '--', $repoSshUrl, $this->config->get('cache-vcs-dir').'/git-github.com-composer-packagist.git/'], [ - 'cmd' => 'git show-ref --tags --dereference', + 'cmd' => ['git', 'show-ref', '--tags', '--dereference'], 'stdout' => $sha.' refs/tags/'.$identifier, ], [ - 'cmd' => 'git branch --no-color --no-abbrev -v', + 'cmd' => ['git', 'branch', '--no-color', '--no-abbrev', '-v'], 'stdout' => ' test_master edf93f1fccaebd8764383dc12016d0a1a9672d89 Fix test & behavior', ], [ - 'cmd' => 'git branch --no-color', + 'cmd' => ['git', 'branch', '--no-color'], 'stdout' => '* test_master', ], ], true); diff --git a/tests/Composer/Test/Repository/Vcs/HgDriverTest.php b/tests/Composer/Test/Repository/Vcs/HgDriverTest.php index 42d9f5b4a..b4e912af1 100644 --- a/tests/Composer/Test/Repository/Vcs/HgDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/HgDriverTest.php @@ -85,10 +85,10 @@ HG_BOOKMARKS; $process ->expects([[ - 'cmd' => 'hg branches', + 'cmd' => ['hg', 'branches'], 'stdout' => $stdout, ], [ - 'cmd' => 'hg bookmarks', + 'cmd' => ['hg', 'bookmarks'], 'stdout' => $stdout1, ]]); diff --git a/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php b/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php index d8a76f702..7e0b72ba9 100644 --- a/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/SvnDriverTest.php @@ -60,12 +60,7 @@ class SvnDriverTest extends TestCase $output .= " rejected Basic challenge (https://corp.svn.local/)"; $process = $this->getProcessExecutorMock(); - $authedCommand = sprintf( - 'svn ls --verbose --non-interactive --username %s --password %s -- %s', - ProcessExecutor::escape('till'), - ProcessExecutor::escape('secret'), - ProcessExecutor::escape('https://till:secret@corp.svn.local/repo/trunk') - ); + $authedCommand = ['svn', 'ls', '--verbose', '--non-interactive', '--username', 'till', '--password', 'secret', '--', 'https://till:secret@corp.svn.local/repo/trunk']; $process->expects([ ['cmd' => $authedCommand, 'return' => 1, 'stderr' => $output], ['cmd' => $authedCommand, 'return' => 1, 'stderr' => $output], @@ -73,7 +68,7 @@ class SvnDriverTest extends TestCase ['cmd' => $authedCommand, 'return' => 1, 'stderr' => $output], ['cmd' => $authedCommand, 'return' => 1, 'stderr' => $output], ['cmd' => $authedCommand, 'return' => 1, 'stderr' => $output], - ['cmd' => 'svn --version', 'return' => 0, 'stdout' => '1.2.3'], + ['cmd' => ['svn', '--version'], 'return' => 0, 'stdout' => '1.2.3'], ], true); $repoConfig = [ diff --git a/tests/Composer/Test/Util/GitTest.php b/tests/Composer/Test/Util/GitTest.php index 04e804d25..98936459d 100644 --- a/tests/Composer/Test/Util/GitTest.php +++ b/tests/Composer/Test/Util/GitTest.php @@ -57,6 +57,7 @@ class GitTest extends TestCase $this->process->expects(['git command'], true); + // @phpstan-ignore method.deprecated $this->git->runCommand($commandCallable, 'https://github.com/acme/repo', null, true); } @@ -82,9 +83,10 @@ class GitTest extends TestCase $this->process->expects([ ['cmd' => 'git command', 'return' => 1], - ['cmd' => 'git --version', 'return' => 0], + ['cmd' => ['git', '--version'], 'return' => 0], ], true); + // @phpstan-ignore method.deprecated $this->git->runCommand($commandCallable, 'https://github.com/acme/repo', null, true); } @@ -124,6 +126,7 @@ class GitTest extends TestCase ->with($this->equalTo('github.com')) ->willReturn(['username' => 'token', 'password' => $gitHubToken]); + // @phpstan-ignore method.deprecated $this->git->runCommand($commandCallable, $gitUrl, null, true); } @@ -152,7 +155,7 @@ class GitTest extends TestCase // When we are testing what happens without auth saved, and URLs // with https, there will also be an attempt to find the token in // the git config for the folder and repo, locally. - $additional_calls = array_fill(0, $bitbucket_git_auth_calls, ['cmd' => 'git config bitbucket.accesstoken', 'return' => 1]); + $additional_calls = array_fill(0, $bitbucket_git_auth_calls, ['cmd' => ['git', 'config', 'bitbucket.accesstoken'], 'return' => 1]); foreach ($additional_calls as $call) { $expectedCalls[] = $call; } @@ -177,6 +180,7 @@ class GitTest extends TestCase ->with($this->equalTo('bitbucket.org')) ->willReturn(['username' => 'token', 'password' => $bitbucketToken]); } + // @phpstan-ignore method.deprecated $this->git->runCommand($commandCallable, $gitUrl, null, true); } @@ -202,7 +206,7 @@ class GitTest extends TestCase if (count($initial_config) > 0) { $expectedCalls[] = ['cmd' => 'git command failing', 'return' => 1]; } else { - $expectedCalls[] = ['cmd' => 'git config bitbucket.accesstoken', 'return' => 1]; + $expectedCalls[] = ['cmd' => ['git', 'config', 'bitbucket.accesstoken'], 'return' => 1]; } $expectedCalls[] = ['cmd' => 'git command ok', 'return' => 0]; $this->process->expects($expectedCalls, true); @@ -259,6 +263,7 @@ class GitTest extends TestCase ['url' => 'https://bitbucket.org/site/oauth2/access_token', 'status' => 200, 'body' => '{"expires_in": 600, "access_token": "my-access-token"}'] ]); $this->git->setHttpDownloader($downloader_mock); + // @phpstan-ignore method.deprecated $this->git->runCommand($commandCallable, $gitUrl, null, true); } diff --git a/tests/Composer/Test/Util/PerforceTest.php b/tests/Composer/Test/Util/PerforceTest.php index 1994675e0..99a908c57 100644 --- a/tests/Composer/Test/Util/PerforceTest.php +++ b/tests/Composer/Test/Util/PerforceTest.php @@ -561,7 +561,9 @@ class PerforceTest extends TestCase public function testCheckServerExists(): void { $this->processExecutor->expects( - ['p4 -p '.ProcessExecutor::escape('perforce.does.exist:port').' info -s'], + [ + ['p4', '-p', 'perforce.does.exist:port', 'info', '-s'] + ], true ); @@ -578,7 +580,7 @@ class PerforceTest extends TestCase { $processExecutor = $this->getMockBuilder('Composer\Util\ProcessExecutor')->getMock(); - $expectedCommand = 'p4 -p '.ProcessExecutor::escape('perforce.does.exist:port').' info -s'; + $expectedCommand = ['p4', '-p', 'perforce.does.exist:port', 'info', '-s']; $processExecutor->expects($this->once()) ->method('execute') ->with($this->equalTo($expectedCommand), $this->equalTo(null)) diff --git a/tests/Composer/Test/Util/SvnTest.php b/tests/Composer/Test/Util/SvnTest.php index 044bf2eed..b17f722d3 100644 --- a/tests/Composer/Test/Util/SvnTest.php +++ b/tests/Composer/Test/Util/SvnTest.php @@ -23,14 +23,14 @@ class SvnTest extends TestCase * Test the credential string. * * @param string $url The SVN url. - * @param string $expect The expectation for the test. + * @param non-empty-list $expect The expectation for the test. * * @dataProvider urlProvider */ - public function testCredentials(string $url, string $expect): void + public function testCredentials(string $url, array $expect): void { $svn = new Svn($url, new NullIO, new Config()); - $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialString'); + $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialArgs'); $reflMethod->setAccessible(true); self::assertEquals($expect, $reflMethod->invoke($svn)); @@ -39,9 +39,9 @@ class SvnTest extends TestCase public static function urlProvider(): array { return [ - ['http://till:test@svn.example.org/', self::getCmd(" --username 'till' --password 'test' ")], - ['http://svn.apache.org/', ''], - ['svn://johndoe@example.org', self::getCmd(" --username 'johndoe' --password '' ")], + ['http://till:test@svn.example.org/', ['--username', 'till', '--password', 'test']], + ['http://svn.apache.org/', []], + ['svn://johndoe@example.org', ['--username', 'johndoe', '--password', '']], ]; } @@ -54,8 +54,8 @@ class SvnTest extends TestCase $reflMethod->setAccessible(true); self::assertEquals( - self::getCmd("svn ls --non-interactive -- 'http://svn.example.org'"), - $reflMethod->invokeArgs($svn, ['svn ls', $url]) + ['svn', 'ls', '--non-interactive', '--', 'http://svn.example.org'], + $reflMethod->invokeArgs($svn, [['svn', 'ls'], $url]) ); } @@ -73,10 +73,10 @@ class SvnTest extends TestCase ]); $svn = new Svn($url, new NullIO, $config); - $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialString'); + $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialArgs'); $reflMethod->setAccessible(true); - self::assertEquals(self::getCmd(" --username 'foo' --password 'bar' "), $reflMethod->invoke($svn)); + self::assertEquals(['--username', 'foo', '--password', 'bar'], $reflMethod->invoke($svn)); } public function testCredentialsFromConfigWithCacheCredentialsTrue(): void @@ -96,10 +96,10 @@ class SvnTest extends TestCase $svn = new Svn($url, new NullIO, $config); $svn->setCacheCredentials(true); - $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialString'); + $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialArgs'); $reflMethod->setAccessible(true); - self::assertEquals(self::getCmd(" --username 'foo' --password 'bar' "), $reflMethod->invoke($svn)); + self::assertEquals(['--username', 'foo', '--password', 'bar'], $reflMethod->invoke($svn)); } public function testCredentialsFromConfigWithCacheCredentialsFalse(): void @@ -119,9 +119,9 @@ class SvnTest extends TestCase $svn = new Svn($url, new NullIO, $config); $svn->setCacheCredentials(false); - $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialString'); + $reflMethod = new \ReflectionMethod('Composer\\Util\\Svn', 'getCredentialArgs'); $reflMethod->setAccessible(true); - self::assertEquals(self::getCmd(" --no-auth-cache --username 'foo' --password 'bar' "), $reflMethod->invoke($svn)); + self::assertEquals(['--no-auth-cache', '--username', 'foo', '--password', 'bar'], $reflMethod->invoke($svn)); } }