diff --git a/src/Composer/Command/SelfUpdateCommand.php b/src/Composer/Command/SelfUpdateCommand.php index 3bc482e63..2e1d37d43 100644 --- a/src/Composer/Command/SelfUpdateCommand.php +++ b/src/Composer/Command/SelfUpdateCommand.php @@ -16,6 +16,7 @@ use Composer\Composer; use Composer\Factory; use Composer\Config; use Composer\Util\Filesystem; +use Composer\Util\Platform; use Composer\SelfUpdate\Keys; use Composer\SelfUpdate\Versions; use Composer\IO\IOInterface; @@ -106,6 +107,11 @@ EOT return $this->fetchKeys($io, $config); } + // ensure composer.phar location is accessible + if (!file_exists($localFilename)) { + throw new FilesystemException('Composer update failed: the "'.$localFilename.'" is not accessible'); + } + // check if current dir is writable and if not try the cache dir from settings $tmpDir = is_writable(dirname($localFilename)) ? dirname($localFilename) : $cacheDir; @@ -248,10 +254,8 @@ TAGSPUBKEY $this->cleanBackups($rollbackDir); } - if ($err = $this->setLocalPhar($localFilename, $tempFilename, $backupFile)) { + if (!$this->setLocalPhar($localFilename, $tempFilename, $backupFile)) { @unlink($tempFilename); - $io->writeError('The file is corrupted ('.$err->getMessage().').'); - $io->writeError('Please re-run the self-update command to try again.'); return 1; } @@ -331,9 +335,7 @@ TAGSPUBKEY $io = $this->getIO(); $io->writeError(sprintf("Rolling back to version %s.", $rollbackVersion)); - if ($err = $this->setLocalPhar($localFilename, $oldFile)) { - $io->writeError('The backup file was corrupted ('.$err->getMessage().').'); - + if (!$this->setLocalPhar($localFilename, $oldFile)) { return 1; } @@ -341,37 +343,49 @@ TAGSPUBKEY } /** - * @param string $localFilename - * @param string $newFilename - * @param string $backupTarget - * @throws \Exception - * @return \UnexpectedValueException|\PharException|null + * Checks if the downloaded/rollback phar is valid then moves it + * + * @param string $localFilename The composer.phar location + * @param string $newFilename The downloaded or backup phar + * @param string $backupTarget The filename to use for the backup + * @throws \FilesystemException If the file cannot be moved + * @return bool Whether the phar is valid and has been moved */ protected function setLocalPhar($localFilename, $newFilename, $backupTarget = null) { + $io = $this->getIO(); + @chmod($newFilename, fileperms($localFilename)); + + // check phar validity + if (!$this->validatePhar($newFilename, $error)) { + $io->writeError('The '.($backupTarget ? 'update' : 'backup').' file is corrupted ('.$error.')'); + + if ($backupTarget) { + $io->writeError('Please re-run the self-update command to try again.'); + } + + return false; + } + + // copy current file into backups dir + if ($backupTarget) { + @copy($localFilename, $backupTarget); + } + try { - @chmod($newFilename, fileperms($localFilename)); - if (!ini_get('phar.readonly')) { - // test the phar validity - $phar = new \Phar($newFilename); - // free the variable to unlock the file - unset($phar); - } - - // copy current file into installations dir - if ($backupTarget && file_exists($localFilename)) { - @copy($localFilename, $backupTarget); - } - rename($newFilename, $localFilename); - return null; + return true; } catch (\Exception $e) { - if (!$e instanceof \UnexpectedValueException && !$e instanceof \PharException) { - throw $e; + // see if we can run this operation as an Admin on Windows + if (!is_writable(dirname($localFilename)) + && $io->isInteractive() + && $this->isWindowsNonAdminUser($isCygwin)) { + return $this->tryAsWindowsAdmin($localFilename, $newFilename, $isCygwin); } - return $e; + $action = 'Composer '.($backupTarget ? 'update' : 'rollback'); + throw new FilesystemException($action.' failed: "'.$localFilename.'" could not be written.'.PHP_EOL.$e->getMessage()); } } @@ -414,4 +428,129 @@ TAGSPUBKEY return $finder; } + + /** + * Validates the downloaded/backup phar file + * + * @param string $pharFile The downloaded or backup phar + * @param null|string $error Set by method on failure + * + * Code taken from getcomposer.org/installer. Any changes should be made + * there and replicated here + * + * @return bool If the operation succeeded + * @throws \Exception + */ + protected function validatePhar($pharFile, &$error) + { + if (ini_get('phar.readonly')) { + return true; + } + + try { + // Test the phar validity + $phar = new \Phar($pharFile); + // Free the variable to unlock the file + unset($phar); + $result = true; + } catch (\Exception $e) { + if (!$e instanceof \UnexpectedValueException && !$e instanceof \PharException) { + throw $e; + } + $error = $e->getMessage(); + $result = false; + } + + return $result; + } + + /** + * Returns true if this is a non-admin Windows user account + * + * @param null|bool $isCygwin Set by method + * @return bool + */ + protected function isWindowsNonAdminUser(&$isCygwin) + { + $isCygwin = preg_match('/cygwin/i', php_uname()); + + if (!$isCygwin && !Platform::isWindows()) { + return false; + } + + // fltmc.exe manages filter drivers and errors without admin privileges + $command = sprintf('%sfltmc.exe filters', $isCygwin ? 'cmd.exe /c ' : ''); + exec($command, $output, $exitCode); + + return $exitCode !== 0; + } + + /** + * Invokes a UAC prompt to update composer.phar as an admin + * + * Uses a .vbs script to elevate and run the cmd.exe move command. + * + * @param string $localFilename The composer.phar location + * @param string $newFilename The downloaded or backup phar + * @param bool $isCygwin Whether we are running on Cygwin + * @return bool Whether composer.phar has been updated + */ + protected function tryAsWindowsAdmin($localFilename, $newFilename, $isCygwin) + { + $io = $this->getIO(); + + $io->writeError('Unable to write "'.$localFilename.'". Access is denied.'); + $helpMessage = 'Please run the self-update command as an Administrator.'; + $question = 'Complete this operation with Administrator privileges [Y,n]? '; + + if (!$io->askConfirmation($question, false)) { + $io->writeError('Operation cancelled. '.$helpMessage.''); + + return false; + } + + $tmpFile = tempnam(sys_get_temp_dir(), ''); + $script = $tmpFile.'.vbs'; + rename($tmpFile, $script); + + $checksum = hash_file('sha256', $newFilename); + + // format the file names for cmd.exe + if ($isCygwin) { + $source = exec(sprintf("cygpath -w '%s'", $newFilename)); + $destination = exec(sprintf("cygpath -w '%s'", $localFilename)); + } else { + // cmd's internal move is fussy about backslashes + $source = str_replace('/', '\\', $newFilename); + $destination = str_replace('/', '\\', $localFilename); + } + + $vbs = <<writeError('Operation succeeded.'); + } else { + $io->writeError('Operation failed (file not written). '.$helpMessage.''); + }; + + return $result; + } } diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php index ea0c72477..ded2393b6 100644 --- a/src/Composer/Util/GitLab.php +++ b/src/Composer/Util/GitLab.php @@ -119,7 +119,7 @@ class GitLab } $this->io->writeError(sprintf('A token will be created and stored in "%s", your password will never be stored', $this->config->getAuthConfigSource()->getName())); - $this->io->writeError('To revoke access to this token you can visit '.$originUrl.'/profile/applications'); + $this->io->writeError('To revoke access to this token you can visit '.$scheme.'://'.$originUrl.'/profile/applications'); $attemptCounter = 0; @@ -131,12 +131,17 @@ class GitLab // 403 is max login attempts exceeded if (in_array($e->getCode(), array(403, 401))) { if (401 === $e->getCode()) { - $this->io->writeError('Bad credentials.'); + $response = json_decode($e->getResponse(), true); + if (isset($response['error']) && $response['error'] === 'invalid_grant') { + $this->io->writeError('Bad credentials. If you have two factor authentication enabled you will have to manually create a personal access token'); + } else { + $this->io->writeError('Bad credentials.'); + } } else { $this->io->writeError('Maximum number of login attempts exceeded. Please try again later.'); } - $this->io->writeError('You can also manually create a personal token at '.$scheme.'://'.$originUrl.'/profile/personal_access_tokens'); + $this->io->writeError('You can also manually create a personal access token enabling the "read_api" scope at '.$scheme.'://'.$originUrl.'/profile/personal_access_tokens'); $this->io->writeError('Add it using "composer config --global --auth gitlab-token.'.$originUrl.' "'); continue; diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 4bac6a88f..615de0adb 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -351,7 +351,7 @@ class RemoteFilesystem if ($originUrl === 'bitbucket.org' && !$this->authHelper->isPublicBitBucketDownload($fileUrl) && substr($fileUrl, -4) === '.zip' - && (!$locationHeader || substr($locationHeader, -4) !== '.zip') + && (!$locationHeader || substr(parse_url($locationHeader, PHP_URL_PATH), -4) !== '.zip') && $contentType && preg_match('{^text/html\b}i', $contentType) ) { $result = false;