From cae913c434818e1318a336e129bfe4e58f5b8e0f Mon Sep 17 00:00:00 2001 From: johnstevenson Date: Thu, 7 May 2020 19:10:36 +0100 Subject: [PATCH] Add Windows UAC elevation to self-update command If self-update fails on Windows due to file permission issues, a .vbs script is used to elevate a call to the cmd.exe `move` command. Unfortunately it is not possible to know if the user cancelled the UAC prompt using this method - it is possible using a Powershell script, but flashing hidden windows make this a less desirable option. The only downside is that a UAC invoked process is asynchronous, so a 300 millisecond timeout is used to allow cmd.exe to do its stuff. Therefore if the OS is busy the script may finish first and incorrectly report that the file has not been written. --- src/Composer/Command/SelfUpdateCommand.php | 195 ++++++++++++++++++--- 1 file changed, 167 insertions(+), 28 deletions(-) diff --git a/src/Composer/Command/SelfUpdateCommand.php b/src/Composer/Command/SelfUpdateCommand.php index 925270ade..4ac144ea8 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|string $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 priviledges [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; + } }