RemoteFilesystem.php 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. <?php
  2. /*
  3. * This file is part of Composer.
  4. *
  5. * (c) Nils Adermann <naderman@naderman.de>
  6. * Jordi Boggiano <j.boggiano@seld.be>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Composer\Util;
  12. use Composer\Config;
  13. use Composer\IO\IOInterface;
  14. use Composer\Downloader\TransportException;
  15. use Composer\CaBundle\CaBundle;
  16. use Psr\Log\LoggerInterface;
  17. /**
  18. * @author François Pluchino <francois.pluchino@opendisplay.com>
  19. * @author Jordi Boggiano <j.boggiano@seld.be>
  20. * @author Nils Adermann <naderman@naderman.de>
  21. */
  22. class RemoteFilesystem
  23. {
  24. private $io;
  25. private $config;
  26. private $scheme;
  27. private $bytesMax;
  28. private $originUrl;
  29. private $fileUrl;
  30. private $fileName;
  31. private $retry;
  32. private $progress;
  33. private $lastProgress;
  34. private $options = array();
  35. private $peerCertificateMap = array();
  36. private $disableTls = false;
  37. private $retryAuthFailure;
  38. private $lastHeaders;
  39. private $storeAuth;
  40. private $authHelper;
  41. private $degradedMode = false;
  42. private $redirects;
  43. private $maxRedirects = 20;
  44. /**
  45. * Constructor.
  46. *
  47. * @param IOInterface $io The IO instance
  48. * @param Config $config The config
  49. * @param array $options The options
  50. * @param bool $disableTls
  51. */
  52. public function __construct(IOInterface $io, Config $config, array $options = array(), $disableTls = false)
  53. {
  54. $this->io = $io;
  55. // Setup TLS options
  56. // The cafile option can be set via config.json
  57. if ($disableTls === false) {
  58. $logger = $io instanceof LoggerInterface ? $io : null;
  59. $this->options = StreamContextFactory::getTlsDefaults($options, $logger);
  60. } else {
  61. $this->disableTls = true;
  62. }
  63. // handle the other externally set options normally.
  64. $this->options = array_replace_recursive($this->options, $options);
  65. $this->config = $config;
  66. $this->authHelper = new AuthHelper($io, $config);
  67. }
  68. /**
  69. * Copy the remote file in local.
  70. *
  71. * @param string $originUrl The origin URL
  72. * @param string $fileUrl The file URL
  73. * @param string $fileName the local filename
  74. * @param bool $progress Display the progression
  75. * @param array $options Additional context options
  76. *
  77. * @return bool true
  78. */
  79. public function copy($originUrl, $fileUrl, $fileName, $progress = true, $options = array())
  80. {
  81. return $this->get($originUrl, $fileUrl, $options, $fileName, $progress);
  82. }
  83. /**
  84. * Get the content.
  85. *
  86. * @param string $originUrl The origin URL
  87. * @param string $fileUrl The file URL
  88. * @param bool $progress Display the progression
  89. * @param array $options Additional context options
  90. *
  91. * @return bool|string The content
  92. */
  93. public function getContents($originUrl, $fileUrl, $progress = true, $options = array())
  94. {
  95. return $this->get($originUrl, $fileUrl, $options, null, $progress);
  96. }
  97. /**
  98. * Retrieve the options set in the constructor
  99. *
  100. * @return array Options
  101. */
  102. public function getOptions()
  103. {
  104. return $this->options;
  105. }
  106. /**
  107. * Merges new options
  108. *
  109. * @param array $options
  110. */
  111. public function setOptions(array $options)
  112. {
  113. $this->options = array_replace_recursive($this->options, $options);
  114. }
  115. /**
  116. * Check is disable TLS.
  117. *
  118. * @return bool
  119. */
  120. public function isTlsDisabled()
  121. {
  122. return $this->disableTls === true;
  123. }
  124. /**
  125. * Returns the headers of the last request
  126. *
  127. * @return array
  128. */
  129. public function getLastHeaders()
  130. {
  131. return $this->lastHeaders;
  132. }
  133. /**
  134. * @param array $headers array of returned headers like from getLastHeaders()
  135. * @param string $name header name (case insensitive)
  136. * @return string|null
  137. */
  138. public static function findHeaderValue(array $headers, $name)
  139. {
  140. $value = null;
  141. foreach ($headers as $header) {
  142. if (preg_match('{^'.$name.':\s*(.+?)\s*$}i', $header, $match)) {
  143. $value = $match[1];
  144. } elseif (preg_match('{^HTTP/}i', $header)) {
  145. // In case of redirects, http_response_headers contains the headers of all responses
  146. // so we reset the flag when a new response is being parsed as we are only interested in the last response
  147. $value = null;
  148. }
  149. }
  150. return $value;
  151. }
  152. /**
  153. * @param array $headers array of returned headers like from getLastHeaders()
  154. * @return int|null
  155. */
  156. public static function findStatusCode(array $headers)
  157. {
  158. $value = null;
  159. foreach ($headers as $header) {
  160. if (preg_match('{^HTTP/\S+ (\d+)}i', $header, $match)) {
  161. // In case of redirects, http_response_headers contains the headers of all responses
  162. // so we can not return directly and need to keep iterating
  163. $value = (int) $match[1];
  164. }
  165. }
  166. return $value;
  167. }
  168. /**
  169. * @param array $headers array of returned headers like from getLastHeaders()
  170. * @return string|null
  171. */
  172. public function findStatusMessage(array $headers)
  173. {
  174. $value = null;
  175. foreach ($headers as $header) {
  176. if (preg_match('{^HTTP/\S+ \d+}i', $header)) {
  177. // In case of redirects, http_response_headers contains the headers of all responses
  178. // so we can not return directly and need to keep iterating
  179. $value = $header;
  180. }
  181. }
  182. return $value;
  183. }
  184. /**
  185. * Get file content or copy action.
  186. *
  187. * @param string $originUrl The origin URL
  188. * @param string $fileUrl The file URL
  189. * @param array $additionalOptions context options
  190. * @param string $fileName the local filename
  191. * @param bool $progress Display the progression
  192. *
  193. * @throws TransportException|\Exception
  194. * @throws TransportException When the file could not be downloaded
  195. *
  196. * @return bool|string
  197. */
  198. protected function get($originUrl, $fileUrl, $additionalOptions = array(), $fileName = null, $progress = true)
  199. {
  200. $this->scheme = parse_url($fileUrl, PHP_URL_SCHEME);
  201. $this->bytesMax = 0;
  202. $this->originUrl = $originUrl;
  203. $this->fileUrl = $fileUrl;
  204. $this->fileName = $fileName;
  205. $this->progress = $progress;
  206. $this->lastProgress = null;
  207. $this->retryAuthFailure = true;
  208. $this->lastHeaders = array();
  209. $this->redirects = 1; // The first request counts.
  210. $tempAdditionalOptions = $additionalOptions;
  211. if (isset($tempAdditionalOptions['retry-auth-failure'])) {
  212. $this->retryAuthFailure = (bool) $tempAdditionalOptions['retry-auth-failure'];
  213. unset($tempAdditionalOptions['retry-auth-failure']);
  214. }
  215. $isRedirect = false;
  216. if (isset($tempAdditionalOptions['redirects'])) {
  217. $this->redirects = $tempAdditionalOptions['redirects'];
  218. $isRedirect = true;
  219. unset($tempAdditionalOptions['redirects']);
  220. }
  221. $options = $this->getOptionsForUrl($originUrl, $tempAdditionalOptions);
  222. unset($tempAdditionalOptions);
  223. $origFileUrl = $fileUrl;
  224. if (isset($options['gitlab-token'])) {
  225. $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['gitlab-token'];
  226. unset($options['gitlab-token']);
  227. }
  228. if (isset($options['http'])) {
  229. $options['http']['ignore_errors'] = true;
  230. }
  231. if ($this->degradedMode && substr($fileUrl, 0, 26) === 'http://repo.packagist.org/') {
  232. // access packagist using the resolved IPv4 instead of the hostname to force IPv4 protocol
  233. $fileUrl = 'http://' . gethostbyname('repo.packagist.org') . substr($fileUrl, 20);
  234. $degradedPackagist = true;
  235. }
  236. $ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet')));
  237. $actualContextOptions = stream_context_get_options($ctx);
  238. $usingProxy = !empty($actualContextOptions['http']['proxy']) ? ' using proxy ' . $actualContextOptions['http']['proxy'] : '';
  239. $this->io->writeError((substr($origFileUrl, 0, 4) === 'http' ? 'Downloading ' : 'Reading ') . $origFileUrl . $usingProxy, true, IOInterface::DEBUG);
  240. unset($origFileUrl, $actualContextOptions);
  241. // Check for secure HTTP, but allow insecure Packagist calls to $hashed providers as file integrity is verified with sha256
  242. if ((!preg_match('{^http://(repo\.)?packagist\.org/p/}', $fileUrl) || (false === strpos($fileUrl, '$') && false === strpos($fileUrl, '%24'))) && empty($degradedPackagist) && $this->config) {
  243. $this->config->prohibitUrlByConfig($fileUrl, $this->io);
  244. }
  245. if ($this->progress && !$isRedirect) {
  246. $this->io->writeError("Downloading (<comment>connecting...</comment>)", false);
  247. }
  248. $errorMessage = '';
  249. $errorCode = 0;
  250. $result = false;
  251. set_error_handler(function ($code, $msg) use (&$errorMessage) {
  252. if ($errorMessage) {
  253. $errorMessage .= "\n";
  254. }
  255. $errorMessage .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg);
  256. });
  257. try {
  258. $result = $this->getRemoteContents($originUrl, $fileUrl, $ctx, $http_response_header);
  259. if (!empty($http_response_header[0])) {
  260. $statusCode = $this->findStatusCode($http_response_header);
  261. if (in_array($statusCode, array(401, 403)) && $this->retryAuthFailure) {
  262. $warning = null;
  263. if ($this->findHeaderValue($http_response_header, 'content-type') === 'application/json') {
  264. $data = json_decode($result, true);
  265. if (!empty($data['warning'])) {
  266. $warning = $data['warning'];
  267. }
  268. }
  269. $this->promptAuthAndRetry($statusCode, $this->findStatusMessage($http_response_header), $warning, $http_response_header);
  270. }
  271. }
  272. $contentLength = !empty($http_response_header[0]) ? $this->findHeaderValue($http_response_header, 'content-length') : null;
  273. if ($contentLength && Platform::strlen($result) < $contentLength) {
  274. // alas, this is not possible via the stream callback because STREAM_NOTIFY_COMPLETED is documented, but not implemented anywhere in PHP
  275. $e = new TransportException('Content-Length mismatch, received '.Platform::strlen($result).' bytes out of the expected '.$contentLength);
  276. $e->setHeaders($http_response_header);
  277. $e->setStatusCode($this->findStatusCode($http_response_header));
  278. $e->setResponse($result);
  279. $this->io->writeError('Content-Length mismatch, received '.Platform::strlen($result).' out of '.$contentLength.' bytes: (' . base64_encode($result).')', true, IOInterface::DEBUG);
  280. throw $e;
  281. }
  282. if (PHP_VERSION_ID < 50600 && !empty($options['ssl']['peer_fingerprint'])) {
  283. // Emulate fingerprint validation on PHP < 5.6
  284. $params = stream_context_get_params($ctx);
  285. $expectedPeerFingerprint = $options['ssl']['peer_fingerprint'];
  286. $peerFingerprint = TlsHelper::getCertificateFingerprint($params['options']['ssl']['peer_certificate']);
  287. // Constant time compare??!
  288. if ($expectedPeerFingerprint !== $peerFingerprint) {
  289. throw new TransportException('Peer fingerprint did not match');
  290. }
  291. }
  292. } catch (\Exception $e) {
  293. if ($e instanceof TransportException && !empty($http_response_header[0])) {
  294. $e->setHeaders($http_response_header);
  295. $e->setStatusCode($this->findStatusCode($http_response_header));
  296. }
  297. if ($e instanceof TransportException && $result !== false) {
  298. $e->setResponse($result);
  299. }
  300. $result = false;
  301. }
  302. if ($errorMessage && !filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN)) {
  303. $errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')';
  304. }
  305. restore_error_handler();
  306. if (isset($e) && !$this->retry) {
  307. if (!$this->degradedMode && false !== strpos($e->getMessage(), 'Operation timed out')) {
  308. $this->degradedMode = true;
  309. $this->io->writeError('');
  310. $this->io->writeError(array(
  311. '<error>'.$e->getMessage().'</error>',
  312. '<error>Retrying with degraded mode, check https://getcomposer.org/doc/articles/troubleshooting.md#degraded-mode for more info</error>',
  313. ));
  314. return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  315. }
  316. throw $e;
  317. }
  318. $statusCode = null;
  319. $contentType = null;
  320. $locationHeader = null;
  321. if (!empty($http_response_header[0])) {
  322. $statusCode = $this->findStatusCode($http_response_header);
  323. $contentType = $this->findHeaderValue($http_response_header, 'content-type');
  324. $locationHeader = $this->findHeaderValue($http_response_header, 'location');
  325. }
  326. // check for bitbucket login page asking to authenticate
  327. if ($originUrl === 'bitbucket.org'
  328. && !$this->authHelper->isPublicBitBucketDownload($fileUrl)
  329. && substr($fileUrl, -4) === '.zip'
  330. && (!$locationHeader || substr($locationHeader, -4) !== '.zip')
  331. && $contentType && preg_match('{^text/html\b}i', $contentType)
  332. ) {
  333. $result = false;
  334. if ($this->retryAuthFailure) {
  335. $this->promptAuthAndRetry(401);
  336. }
  337. }
  338. // check for gitlab 404 when downloading archives
  339. if ($statusCode === 404
  340. && $this->config && in_array($originUrl, $this->config->get('gitlab-domains'), true)
  341. && false !== strpos($fileUrl, 'archive.zip')
  342. ) {
  343. $result = false;
  344. if ($this->retryAuthFailure) {
  345. $this->promptAuthAndRetry(401);
  346. }
  347. }
  348. // handle 3xx redirects, 304 Not Modified is excluded
  349. $hasFollowedRedirect = false;
  350. if ($statusCode >= 300 && $statusCode <= 399 && $statusCode !== 304 && $this->redirects < $this->maxRedirects) {
  351. $hasFollowedRedirect = true;
  352. $result = $this->handleRedirect($http_response_header, $additionalOptions, $result);
  353. }
  354. // fail 4xx and 5xx responses and capture the response
  355. if ($statusCode && $statusCode >= 400 && $statusCode <= 599) {
  356. if (!$this->retry) {
  357. if ($this->progress && !$this->retry && !$isRedirect) {
  358. $this->io->overwriteError("Downloading (<error>failed</error>)", false);
  359. }
  360. $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded ('.$http_response_header[0].')', $statusCode);
  361. $e->setHeaders($http_response_header);
  362. $e->setResponse($result);
  363. $e->setStatusCode($statusCode);
  364. throw $e;
  365. }
  366. $result = false;
  367. }
  368. if ($this->progress && !$this->retry && !$isRedirect) {
  369. $this->io->overwriteError("Downloading (".($result === false ? '<error>failed</error>' : '<comment>100%</comment>').")", false);
  370. }
  371. // decode gzip
  372. if ($result && extension_loaded('zlib') && substr($fileUrl, 0, 4) === 'http' && !$hasFollowedRedirect) {
  373. $contentEncoding = $this->findHeaderValue($http_response_header, 'content-encoding');
  374. $decode = $contentEncoding && 'gzip' === strtolower($contentEncoding);
  375. if ($decode) {
  376. try {
  377. if (PHP_VERSION_ID >= 50400) {
  378. $result = zlib_decode($result);
  379. } else {
  380. // work around issue with gzuncompress & co that do not work with all gzip checksums
  381. $result = file_get_contents('compress.zlib://data:application/octet-stream;base64,'.base64_encode($result));
  382. }
  383. if (!$result) {
  384. throw new TransportException('Failed to decode zlib stream');
  385. }
  386. } catch (\Exception $e) {
  387. if ($this->degradedMode) {
  388. throw $e;
  389. }
  390. $this->degradedMode = true;
  391. $this->io->writeError(array(
  392. '',
  393. '<error>Failed to decode response: '.$e->getMessage().'</error>',
  394. '<error>Retrying with degraded mode, check https://getcomposer.org/doc/articles/troubleshooting.md#degraded-mode for more info</error>',
  395. ));
  396. return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  397. }
  398. }
  399. }
  400. // handle copy command if download was successful
  401. if (false !== $result && null !== $fileName && !$isRedirect) {
  402. if ('' === $result) {
  403. throw new TransportException('"'.$this->fileUrl.'" appears broken, and returned an empty 200 response');
  404. }
  405. $errorMessage = '';
  406. set_error_handler(function ($code, $msg) use (&$errorMessage) {
  407. if ($errorMessage) {
  408. $errorMessage .= "\n";
  409. }
  410. $errorMessage .= preg_replace('{^file_put_contents\(.*?\): }', '', $msg);
  411. });
  412. $result = (bool) file_put_contents($fileName, $result);
  413. restore_error_handler();
  414. if (false === $result) {
  415. throw new TransportException('The "'.$this->fileUrl.'" file could not be written to '.$fileName.': '.$errorMessage);
  416. }
  417. }
  418. // Handle SSL cert match issues
  419. if (false === $result && false !== strpos($errorMessage, 'Peer certificate') && PHP_VERSION_ID < 50600) {
  420. // Certificate name error, PHP doesn't support subjectAltName on PHP < 5.6
  421. // The procedure to handle sAN for older PHP's is:
  422. //
  423. // 1. Open socket to remote server and fetch certificate (disabling peer
  424. // validation because PHP errors without giving up the certificate.)
  425. //
  426. // 2. Verifying the domain in the URL against the names in the sAN field.
  427. // If there is a match record the authority [host/port], certificate
  428. // common name, and certificate fingerprint.
  429. //
  430. // 3. Retry the original request but changing the CN_match parameter to
  431. // the common name extracted from the certificate in step 2.
  432. //
  433. // 4. To prevent any attempt at being hoodwinked by switching the
  434. // certificate between steps 2 and 3 the fingerprint of the certificate
  435. // presented in step 3 is compared against the one recorded in step 2.
  436. if (CaBundle::isOpensslParseSafe()) {
  437. $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options);
  438. if ($certDetails) {
  439. $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails;
  440. $this->retry = true;
  441. }
  442. } else {
  443. $this->io->writeError('');
  444. $this->io->writeError(sprintf(
  445. '<error>Your version of PHP, %s, is affected by CVE-2013-6420 and cannot safely perform certificate validation, we strongly suggest you upgrade.</error>',
  446. PHP_VERSION
  447. ));
  448. }
  449. }
  450. if ($this->retry) {
  451. $this->retry = false;
  452. $result = $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  453. if ($this->storeAuth && $this->config) {
  454. $this->authHelper->storeAuth($this->originUrl, $this->storeAuth);
  455. $this->storeAuth = false;
  456. }
  457. return $result;
  458. }
  459. if (false === $result) {
  460. $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded: '.$errorMessage, $errorCode);
  461. if (!empty($http_response_header[0])) {
  462. $e->setHeaders($http_response_header);
  463. }
  464. if (!$this->degradedMode && false !== strpos($e->getMessage(), 'Operation timed out')) {
  465. $this->degradedMode = true;
  466. $this->io->writeError('');
  467. $this->io->writeError(array(
  468. '<error>'.$e->getMessage().'</error>',
  469. '<error>Retrying with degraded mode, check https://getcomposer.org/doc/articles/troubleshooting.md#degraded-mode for more info</error>',
  470. ));
  471. return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  472. }
  473. throw $e;
  474. }
  475. if (!empty($http_response_header[0])) {
  476. $this->lastHeaders = $http_response_header;
  477. }
  478. return $result;
  479. }
  480. /**
  481. * Get contents of remote URL.
  482. *
  483. * @param string $originUrl The origin URL
  484. * @param string $fileUrl The file URL
  485. * @param resource $context The stream context
  486. *
  487. * @return string|false The response contents or false on failure
  488. */
  489. protected function getRemoteContents($originUrl, $fileUrl, $context, array &$responseHeaders = null)
  490. {
  491. try {
  492. $e = null;
  493. $result = file_get_contents($fileUrl, false, $context);
  494. } catch (\Throwable $e) {
  495. } catch (\Exception $e) {
  496. }
  497. $responseHeaders = isset($http_response_header) ? $http_response_header : array();
  498. if (null !== $e) {
  499. throw $e;
  500. }
  501. return $result;
  502. }
  503. /**
  504. * Get notification action.
  505. *
  506. * @param int $notificationCode The notification code
  507. * @param int $severity The severity level
  508. * @param string $message The message
  509. * @param int $messageCode The message code
  510. * @param int $bytesTransferred The loaded size
  511. * @param int $bytesMax The total size
  512. * @throws TransportException
  513. */
  514. protected function callbackGet($notificationCode, $severity, $message, $messageCode, $bytesTransferred, $bytesMax)
  515. {
  516. switch ($notificationCode) {
  517. case STREAM_NOTIFY_FAILURE:
  518. if (400 === $messageCode) {
  519. // This might happen if your host is secured by ssl client certificate authentication
  520. // but you do not send an appropriate certificate
  521. throw new TransportException("The '" . $this->fileUrl . "' URL could not be accessed: " . $message, $messageCode);
  522. }
  523. break;
  524. case STREAM_NOTIFY_FILE_SIZE_IS:
  525. $this->bytesMax = $bytesMax;
  526. break;
  527. case STREAM_NOTIFY_PROGRESS:
  528. if ($this->bytesMax > 0 && $this->progress) {
  529. $progression = min(100, round($bytesTransferred / $this->bytesMax * 100));
  530. if ((0 === $progression % 5) && 100 !== $progression && $progression !== $this->lastProgress) {
  531. $this->lastProgress = $progression;
  532. $this->io->overwriteError("Downloading (<comment>$progression%</comment>)", false);
  533. }
  534. }
  535. break;
  536. default:
  537. break;
  538. }
  539. }
  540. protected function promptAuthAndRetry($httpStatus, $reason = null, $warning = null, $headers = array())
  541. {
  542. $result = $this->authHelper->promptAuthIfNeeded($this->fileUrl, $this->originUrl, $httpStatus, $reason, $warning, $headers);
  543. $this->storeAuth = $result['storeAuth'];
  544. $this->retry = $result['retry'];
  545. if ($this->retry) {
  546. throw new TransportException('RETRY');
  547. }
  548. }
  549. protected function getOptionsForUrl($originUrl, $additionalOptions)
  550. {
  551. $tlsOptions = array();
  552. // Setup remaining TLS options - the matching may need monitoring, esp. www vs none in CN
  553. if ($this->disableTls === false && PHP_VERSION_ID < 50600 && !stream_is_local($this->fileUrl)) {
  554. $host = parse_url($this->fileUrl, PHP_URL_HOST);
  555. if (PHP_VERSION_ID < 50304) {
  556. // PHP < 5.3.4 does not support follow_location, for those people
  557. // do some really nasty hard coded transformations. These will
  558. // still breakdown if the site redirects to a domain we don't
  559. // expect.
  560. if ($host === 'github.com' || $host === 'api.github.com') {
  561. $host = '*.github.com';
  562. }
  563. }
  564. $tlsOptions['ssl']['CN_match'] = $host;
  565. $tlsOptions['ssl']['SNI_server_name'] = $host;
  566. $urlAuthority = $this->getUrlAuthority($this->fileUrl);
  567. if (isset($this->peerCertificateMap[$urlAuthority])) {
  568. // Handle subjectAltName on lesser PHP's.
  569. $certMap = $this->peerCertificateMap[$urlAuthority];
  570. $this->io->writeError('', true, IOInterface::DEBUG);
  571. $this->io->writeError(sprintf(
  572. 'Using <info>%s</info> as CN for subjectAltName enabled host <info>%s</info>',
  573. $certMap['cn'],
  574. $urlAuthority
  575. ), true, IOInterface::DEBUG);
  576. $tlsOptions['ssl']['CN_match'] = $certMap['cn'];
  577. $tlsOptions['ssl']['peer_fingerprint'] = $certMap['fp'];
  578. } elseif (!CaBundle::isOpensslParseSafe() && $host === 'repo.packagist.org') {
  579. // handle subjectAltName for packagist.org's repo domain on very old PHPs
  580. $tlsOptions['ssl']['CN_match'] = 'packagist.org';
  581. }
  582. }
  583. $headers = array();
  584. if (extension_loaded('zlib')) {
  585. $headers[] = 'Accept-Encoding: gzip';
  586. }
  587. $options = array_replace_recursive($this->options, $tlsOptions, $additionalOptions);
  588. if (!$this->degradedMode) {
  589. // degraded mode disables HTTP/1.1 which causes issues with some bad
  590. // proxies/software due to the use of chunked encoding
  591. $options['http']['protocol_version'] = 1.1;
  592. $headers[] = 'Connection: close';
  593. }
  594. $headers = $this->authHelper->addAuthenticationHeader($headers, $originUrl, $this->fileUrl);
  595. $options['http']['follow_location'] = 0;
  596. if (isset($options['http']['header']) && !is_array($options['http']['header'])) {
  597. $options['http']['header'] = explode("\r\n", trim($options['http']['header'], "\r\n"));
  598. }
  599. foreach ($headers as $header) {
  600. $options['http']['header'][] = $header;
  601. }
  602. return $options;
  603. }
  604. private function handleRedirect(array $http_response_header, array $additionalOptions, $result)
  605. {
  606. if ($locationHeader = $this->findHeaderValue($http_response_header, 'location')) {
  607. if (parse_url($locationHeader, PHP_URL_SCHEME)) {
  608. // Absolute URL; e.g. https://example.com/composer
  609. $targetUrl = $locationHeader;
  610. } elseif (parse_url($locationHeader, PHP_URL_HOST)) {
  611. // Scheme relative; e.g. //example.com/foo
  612. $targetUrl = $this->scheme.':'.$locationHeader;
  613. } elseif ('/' === $locationHeader[0]) {
  614. // Absolute path; e.g. /foo
  615. $urlHost = parse_url($this->fileUrl, PHP_URL_HOST);
  616. // Replace path using hostname as an anchor.
  617. $targetUrl = preg_replace('{^(.+(?://|@)'.preg_quote($urlHost).'(?::\d+)?)(?:[/\?].*)?$}', '\1'.$locationHeader, $this->fileUrl);
  618. } else {
  619. // Relative path; e.g. foo
  620. // This actually differs from PHP which seems to add duplicate slashes.
  621. $targetUrl = preg_replace('{^(.+/)[^/?]*(?:\?.*)?$}', '\1'.$locationHeader, $this->fileUrl);
  622. }
  623. }
  624. if (!empty($targetUrl)) {
  625. $this->redirects++;
  626. $this->io->writeError('', true, IOInterface::DEBUG);
  627. $this->io->writeError(sprintf('Following redirect (%u) %s', $this->redirects, $targetUrl), true, IOInterface::DEBUG);
  628. $additionalOptions['redirects'] = $this->redirects;
  629. return $this->get(parse_url($targetUrl, PHP_URL_HOST), $targetUrl, $additionalOptions, $this->fileName, $this->progress);
  630. }
  631. if (!$this->retry) {
  632. $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded, got redirect without Location ('.$http_response_header[0].')');
  633. $e->setHeaders($http_response_header);
  634. $e->setResponse($result);
  635. throw $e;
  636. }
  637. return false;
  638. }
  639. /**
  640. * Fetch certificate common name and fingerprint for validation of SAN.
  641. *
  642. * @todo Remove when PHP 5.6 is minimum supported version.
  643. */
  644. private function getCertificateCnAndFp($url, $options)
  645. {
  646. if (PHP_VERSION_ID >= 50600) {
  647. throw new \BadMethodCallException(sprintf(
  648. '%s must not be used on PHP >= 5.6',
  649. __METHOD__
  650. ));
  651. }
  652. $context = StreamContextFactory::getContext($url, $options, array('options' => array(
  653. 'ssl' => array(
  654. 'capture_peer_cert' => true,
  655. 'verify_peer' => false, // Yes this is fucking insane! But PHP is lame.
  656. ), ),
  657. ));
  658. // Ideally this would just use stream_socket_client() to avoid sending a
  659. // HTTP request but that does not capture the certificate.
  660. if (false === $handle = @fopen($url, 'rb', false, $context)) {
  661. return;
  662. }
  663. // Close non authenticated connection without reading any content.
  664. fclose($handle);
  665. $handle = null;
  666. $params = stream_context_get_params($context);
  667. if (!empty($params['options']['ssl']['peer_certificate'])) {
  668. $peerCertificate = $params['options']['ssl']['peer_certificate'];
  669. if (TlsHelper::checkCertificateHost($peerCertificate, parse_url($url, PHP_URL_HOST), $commonName)) {
  670. return array(
  671. 'cn' => $commonName,
  672. 'fp' => TlsHelper::getCertificateFingerprint($peerCertificate),
  673. );
  674. }
  675. }
  676. }
  677. private function getUrlAuthority($url)
  678. {
  679. $defaultPorts = array(
  680. 'ftp' => 21,
  681. 'http' => 80,
  682. 'https' => 443,
  683. 'ssh2.sftp' => 22,
  684. 'ssh2.scp' => 22,
  685. );
  686. $scheme = parse_url($url, PHP_URL_SCHEME);
  687. if (!isset($defaultPorts[$scheme])) {
  688. throw new \InvalidArgumentException(sprintf(
  689. 'Could not get default port for unknown scheme: %s',
  690. $scheme
  691. ));
  692. }
  693. $defaultPort = $defaultPorts[$scheme];
  694. $port = parse_url($url, PHP_URL_PORT) ?: $defaultPort;
  695. return parse_url($url, PHP_URL_HOST).':'.$port;
  696. }
  697. }