diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f834254be153..9121a1a5989a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b526cfa5b95..e5c61cc77bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +### [2.2.4] 2022-01-08 + + * Fixed handling of process timeout when running async processes during installation + * Fixed GitLab API handling when projects have a repository disabled (#10440) + * Fixed reading of environment variables (e.g. APPDATA) containing unicode characters to workaround a PHP bug on Windows (#10434) + * Fixed partial update issues with path repos missing if a path repo is required by a path repo (#10431) + * Fixed support for sourcing binaries via the new bin proxies ([#10389](https://github.com/composer/composer/issues/10389#issuecomment-1007372740)) + * Fixed messaging when GitHub tokens need SSO authorization (#10432) + ### [2.2.3] 2021-12-31 * Fixed issue with PHPUnit and process isolation now including PHPUnit <6.5 (#10387) @@ -1358,6 +1367,7 @@ * Initial release +[2.2.4]: https://github.com/composer/composer/compare/2.2.3...2.2.4 [2.2.3]: https://github.com/composer/composer/compare/2.2.2...2.2.3 [2.2.2]: https://github.com/composer/composer/compare/2.2.1...2.2.2 [2.2.1]: https://github.com/composer/composer/compare/2.2.0...2.2.1 diff --git a/bin/composer b/bin/composer index cee476b6d7de..9bb3559a4226 100755 --- a/bin/composer +++ b/bin/composer @@ -65,6 +65,16 @@ if (function_exists('ini_set')) { unset($memoryLimit); } +// Workaround PHP bug on Windows where env vars containing Unicode chars are mangled in $_SERVER +// see https://github.com/php/php-src/issues/7896 +if (PHP_VERSION_ID >= 70113 && Platform::isWindows()) { + foreach ($_SERVER as $serverVar => $serverVal) { + if (($serverVal = getenv($serverVar)) !== false) { + $_SERVER[$serverVar] = $serverVal; + } + } +} + Platform::putEnv('COMPOSER_BINARY', realpath($_SERVER['argv'][0])); ErrorHandler::register(); diff --git a/doc/articles/plugins.md b/doc/articles/plugins.md index 6dc607c6e39a..e3df2cf9514c 100644 --- a/doc/articles/plugins.md +++ b/doc/articles/plugins.md @@ -39,7 +39,7 @@ requirements: The required version of the `composer-plugin-api` follows the same [rules][7] as a normal package's rules. -The current Composer plugin API version is `2.1.0`. +The current Composer plugin API version is `2.2.0`. An example of a valid plugin `composer.json` file (with the autoloading part omitted and an optional require-dev dependency on `composer/composer` for IDE auto completion): diff --git a/src/Composer/Composer.php b/src/Composer/Composer.php index 229be6a4bfa5..d867aba32f69 100644 --- a/src/Composer/Composer.php +++ b/src/Composer/Composer.php @@ -52,9 +52,9 @@ class Composer * const RELEASE_DATE = '@release_date@'; * const SOURCE_VERSION = '1.8-dev+source'; */ - const VERSION = '2.2.3'; + const VERSION = '2.2.4'; const BRANCH_ALIAS_VERSION = ''; - const RELEASE_DATE = '2021-12-31 12:18:53'; + const RELEASE_DATE = '2022-01-08 12:30:42'; const SOURCE_VERSION = ''; /** diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index b5af14af84fc..e7c3a32a5604 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -348,7 +348,7 @@ function_exists('php_uname') ? php_uname('s') . ' / ' . php_uname('r') : 'Unknow return $result; } catch (ScriptExecutionException $e) { - return (int) $e->getCode(); + return $e->getCode(); } catch (\Exception $e) { $ghe = new GithubActionError($this->io); $ghe->emit($e->getMessage()); diff --git a/src/Composer/DependencyResolver/PoolBuilder.php b/src/Composer/DependencyResolver/PoolBuilder.php index 9ff30632410c..de7626fe4f23 100644 --- a/src/Composer/DependencyResolver/PoolBuilder.php +++ b/src/Composer/DependencyResolver/PoolBuilder.php @@ -459,6 +459,10 @@ private function loadPackage(Request $request, array $repositories, BasePackage } } } + } elseif (isset($this->pathRepoUnlocked[$require]) && !isset($this->loadedPackages[$require])) { + // if doing a partial update and a package depends on a path-repo-unlocked package which is not referenced by the root, we need to ensure it gets loaded as it was not loaded by the request's root requirements + // and would not be loaded above if update propagation is not allowed (which happens if the requirer is itself a path-repo-unlocked package) or if transitive deps are not allowed to be unlocked + $this->markPackageNameForLoading($request, $require, $linkConstraint); } } else { $this->markPackageNameForLoading($request, $require, $linkConstraint); diff --git a/src/Composer/DependencyResolver/SolverProblemsException.php b/src/Composer/DependencyResolver/SolverProblemsException.php index 27deac4b32ff..deaec7d24c29 100644 --- a/src/Composer/DependencyResolver/SolverProblemsException.php +++ b/src/Composer/DependencyResolver/SolverProblemsException.php @@ -17,6 +17,8 @@ /** * @author Nils Adermann + * + * @method self::ERROR_DEPENDENCY_RESOLUTION_FAILED getCode() */ class SolverProblemsException extends \RuntimeException { @@ -39,14 +41,6 @@ public function __construct(array $problems, array $learnedPool) parent::__construct('Failed resolving dependencies with '.count($problems).' problems, call getPrettyString to get formatted details', self::ERROR_DEPENDENCY_RESOLUTION_FAILED); } - /** - * @return self::ERROR_DEPENDENCY_RESOLUTION_FAILED - */ - public function getExitCode() - { - return self::ERROR_DEPENDENCY_RESOLUTION_FAILED; - } - /** * @param bool $isVerbose * @param bool $isDevExtraction diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 78329042d2fc..c4571ef85af7 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -384,6 +384,7 @@ public function run() * @param bool $doInstall * * @return int + * @phpstan-return self::ERROR_* */ protected function doUpdate(InstalledRepositoryInterface $localRepo, $doInstall) { @@ -455,7 +456,7 @@ protected function doUpdate(InstalledRepositoryInterface $localRepo, $doInstall) $ghe = new GithubActionError($this->io); $ghe->emit($err."\n".$prettyProblem); - return max(self::ERROR_GENERIC_FAILURE, $e->getExitCode()); + return max(self::ERROR_GENERIC_FAILURE, $e->getCode()); } $this->io->writeError("Analyzed ".count($pool)." packages to resolve dependencies", true, IOInterface::VERBOSE); @@ -633,7 +634,7 @@ protected function extractDevPackages(LockTransaction $lockTransaction, Platform $ghe = new GithubActionError($this->io); $ghe->emit($err."\n".$prettyProblem); - return max(self::ERROR_GENERIC_FAILURE, $e->getExitCode()); + return $e->getCode(); } $lockTransaction->setNonDevPackages($nonDevLockTransaction); @@ -645,6 +646,7 @@ protected function extractDevPackages(LockTransaction $lockTransaction, Platform * @param InstalledRepositoryInterface $localRepo * @param bool $alreadySolved Whether the function is called as part of an update command or independently * @return int exit code + * @phpstan-return self::ERROR_* */ protected function doInstall(InstalledRepositoryInterface $localRepo, $alreadySolved = false) { @@ -703,7 +705,7 @@ protected function doInstall(InstalledRepositoryInterface $localRepo, $alreadySo $ghe = new GithubActionError($this->io); $ghe->emit($err."\n".$prettyProblem); - return max(self::ERROR_GENERIC_FAILURE, $e->getExitCode()); + return max(self::ERROR_GENERIC_FAILURE, $e->getCode()); } } diff --git a/src/Composer/Installer/BinaryInstaller.php b/src/Composer/Installer/BinaryInstaller.php index 12787d48c4f3..03bd0839f40a 100644 --- a/src/Composer/Installer/BinaryInstaller.php +++ b/src/Composer/Installer/BinaryInstaller.php @@ -397,10 +397,16 @@ public function url_stat(\$path, \$flags) return <</dev/null 2>&1) -if [ -z "\$self" ] -then - self="\$0" +# Support bash to support `source` with fallback on $0 if this does not run with bash +# https://stackoverflow.com/a/35006505/6512 +selfArg="\$BASH_SOURCE" +if [ -z "\$selfArg" ]; then + selfArg="\$0" +fi + +self=\$(realpath \$selfArg 2> /dev/null) +if [ -z "\$self" ]; then + self="\$selfArg" fi dir=\$(cd "\${self%[/\\\\]*}" > /dev/null; cd $binDir && pwd) @@ -416,6 +422,15 @@ public function url_stat(\$path, \$flags) export COMPOSER_BIN_DIR=\$(cd "\${self%[/\\\\]*}" > /dev/null; pwd) +# If bash is sourcing this file, we have to source the target as well +bashSource="\$BASH_SOURCE" +if [ -n "\$bashSource" ]; then + if [ "\$bashSource" != "\$0" ]; then + source "\${dir}/$binFile" "\$@" + return + fi +fi + "\${dir}/$binFile" "\$@" PROXY; diff --git a/src/Composer/Repository/Vcs/GitLabDriver.php b/src/Composer/Repository/Vcs/GitLabDriver.php index e775dd269e1c..dd7cb65b2a86 100644 --- a/src/Composer/Repository/Vcs/GitLabDriver.php +++ b/src/Composer/Repository/Vcs/GitLabDriver.php @@ -504,6 +504,11 @@ protected function getContents($url, $fetchingRepoData = false) // force auth as the unauthenticated version of the API is broken if (!isset($json['default_branch'])) { + // GitLab allows you to disable the repository inside a project to use a project only for issues and wiki + if (isset($json['repository_access_level']) && $json['repository_access_level'] === 'disabled') { + throw new TransportException('The GitLab repository is disabled in the project', 400); + } + if (!empty($json['id'])) { $this->isPrivate = false; } diff --git a/src/Composer/Util/AuthHelper.php b/src/Composer/Util/AuthHelper.php index daac4943904b..f49373397913 100644 --- a/src/Composer/Util/AuthHelper.php +++ b/src/Composer/Util/AuthHelper.php @@ -92,6 +92,23 @@ public function promptAuthIfNeeded($url, $origin, $statusCode, $reason = null, $ $message = "\n"; $rateLimited = $gitHubUtil->isRateLimited($headers); + $requiresSso = $gitHubUtil->requiresSso($headers); + + if ($requiresSso) { + $ssoUrl = $gitHubUtil->getSsoUrl($headers); + $message = sprintf( + 'GitHub API token requires SSO authorization. Authorize this token at ' . $ssoUrl, + $ssoUrl + ) . "\n"; + $this->io->writeError($message); + if (!$this->io->isInteractive()) { + throw new TransportException('Could not authenticate against ' . $origin, 403); + } + $this->io->ask('After authorizing your token, confirm that you would like to retry the request'); + + return array('retry' => true, 'storeAuth' => $storeAuth); + } + if ($rateLimited) { $rateLimit = $gitHubUtil->getRateLimit($headers); if ($this->io->hasAuthentication($origin)) { diff --git a/src/Composer/Util/GitHub.php b/src/Composer/Util/GitHub.php index 1c5b9a2aa44b..2dd53e3506c6 100644 --- a/src/Composer/Util/GitHub.php +++ b/src/Composer/Util/GitHub.php @@ -171,6 +171,28 @@ public function getRateLimit(array $headers) return $rateLimit; } + /** + * Extract SSO URL from response. + * + * @param string[] $headers Headers from Composer\Downloader\TransportException. + * + * @return string|null + */ + public function getSsoUrl(array $headers) + { + foreach ($headers as $header) { + $header = trim($header); + if (false === stripos($header, 'x-github-sso: required')) { + continue; + } + if (Preg::isMatch('{\burl=(?P[^\s;]+)}', $header, $match)) { + return $match['url']; + } + } + + return null; + } + /** * Finds whether a request failed due to rate limiting * @@ -188,4 +210,24 @@ public function isRateLimited(array $headers) return false; } + + /** + * Finds whether a request failed due to lacking SSO authorization + * + * @see https://docs.github.com/en/rest/overview/other-authentication-methods#authenticating-for-saml-sso + * + * @param string[] $headers Headers from Composer\Downloader\TransportException. + * + * @return bool + */ + public function requiresSso(array $headers) + { + foreach ($headers as $header) { + if (Preg::isMatch('{^X-GitHub-SSO: required}i', trim($header))) { + return true; + } + } + + return false; + } } diff --git a/src/Composer/Util/ProcessExecutor.php b/src/Composer/Util/ProcessExecutor.php index afb3a0a0786a..16352bbcf7de 100644 --- a/src/Composer/Util/ProcessExecutor.php +++ b/src/Composer/Util/ProcessExecutor.php @@ -102,18 +102,7 @@ public function executeTty($command, $cwd = null) */ private function doExecute($command, $cwd, $tty, &$output = null) { - if ($this->io && $this->io->isDebug()) { - $safeCommand = Preg::replaceCallback('{://(?P[^:/\s]+):(?P[^@\s/]+)@}i', function ($m) { - // if the username looks like a long (12char+) hex string, or a modern github token (e.g. ghp_xxx) we obfuscate that - if (Preg::isMatch('{^([a-f0-9]{12,}|gh[a-z]_[a-zA-Z0-9_]+)$}', $m['user'])) { - return '://***:***@'; - } - - return '://'.$m['user'].':***@'; - }, $command); - $safeCommand = Preg::replace("{--password (.*[^\\\\]\') }", '--password \'***\' ', $safeCommand); - $this->io->writeError('Executing command ('.($cwd ?: 'CWD').'): '.$safeCommand); - } + $this->outputCommandRun($command, $cwd, false); // TODO in 2.2, these two checks can be dropped as Symfony 4+ supports them out of the box // make sure that null translate to the proper directory in case the dir is a symlink @@ -249,17 +238,7 @@ private function startJob($id) $command = $job['command']; $cwd = $job['cwd']; - if ($this->io && $this->io->isDebug()) { - $safeCommand = Preg::replaceCallback('{://(?P[^:/\s]+):(?P[^@\s/]+)@}i', function ($m) { - if (Preg::isMatch('{^[a-f0-9]{12,}$}', $m['user'])) { - return '://***:***@'; - } - - return '://'.$m['user'].':***@'; - }, $command); - $safeCommand = Preg::replace("{--password (.*[^\\\\]\') }", '--password \'***\' ', $safeCommand); - $this->io->writeError('Executing async command ('.($cwd ?: 'CWD').'): '.$safeCommand); - } + $this->outputCommandRun($command, $cwd, true); // TODO in 2.2, these two checks can be dropped as Symfony 4+ supports them out of the box // make sure that null translate to the proper directory in case the dir is a symlink @@ -342,6 +321,8 @@ public function countActiveJobs($index = null) if (!$job['process']->isRunning()) { call_user_func($job['resolve'], $job['process']); } + + $job['process']->checkTimeout(); } if ($this->runningJobs < $this->maxJobs) { @@ -454,6 +435,33 @@ public static function escape($argument) return self::escapeArgument($argument); } + /** + * @param string $command + * @param ?string $cwd + * @param bool $async + * @return void + */ + private function outputCommandRun($command, $cwd, $async) + { + if (null === $this->io || !$this->io->isDebug()) { + return; + } + + $safeCommand = Preg::replaceCallback('{://(?P[^:/\s]+):(?P[^@\s/]+)@}i', function ($m) { + // if the username looks like a long (12char+) hex string, or a modern github token (e.g. ghp_xxx) we obfuscate that + if (Preg::isMatch('{^([a-f0-9]{12,}|gh[a-z]_[a-zA-Z0-9_]+)$}', $m['user'])) { + return '://***:***@'; + } + if (Preg::isMatch('{^[a-f0-9]{12,}$}', $m['user'])) { + return '://***:***@'; + } + + return '://'.$m['user'].':***@'; + }, $command); + $safeCommand = Preg::replace("{--password (.*[^\\\\]\') }", '--password \'***\' ', $safeCommand); + $this->io->writeError('Executing'.($async ? ' async' : '').' command ('.($cwd ?: 'CWD').'): '.$safeCommand); + } + /** * Escapes a string to be used as a shell argument for Symfony Process. *