RemoteFilesystem.php 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  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\Composer;
  14. use Composer\Semver\Constraint\Constraint;
  15. use Composer\Package\Version\VersionParser;
  16. use Composer\IO\IOInterface;
  17. use Composer\Downloader\TransportException;
  18. use Composer\CaBundle\CaBundle;
  19. use Psr\Log\LoggerInterface;
  20. /**
  21. * @author François Pluchino <francois.pluchino@opendisplay.com>
  22. * @author Jordi Boggiano <j.boggiano@seld.be>
  23. * @author Nils Adermann <naderman@naderman.de>
  24. */
  25. class RemoteFilesystem
  26. {
  27. private $io;
  28. private $config;
  29. private $scheme;
  30. private $bytesMax;
  31. private $originUrl;
  32. private $fileUrl;
  33. private $fileName;
  34. private $retry;
  35. private $progress;
  36. private $lastProgress;
  37. private $options = array();
  38. private $peerCertificateMap = array();
  39. private $disableTls = false;
  40. private $retryAuthFailure;
  41. private $lastHeaders;
  42. private $storeAuth;
  43. private $degradedMode = false;
  44. private $redirects;
  45. private $maxRedirects = 20;
  46. private $displayedOriginAuthentications = array();
  47. /**
  48. * Constructor.
  49. *
  50. * @param IOInterface $io The IO instance
  51. * @param Config $config The config
  52. * @param array $options The options
  53. * @param bool $disableTls
  54. */
  55. public function __construct(IOInterface $io, Config $config = null, array $options = array(), $disableTls = false)
  56. {
  57. $this->io = $io;
  58. // Setup TLS options
  59. // The cafile option can be set via config.json
  60. if ($disableTls === false) {
  61. $this->options = $this->getTlsDefaults($options);
  62. } else {
  63. $this->disableTls = true;
  64. }
  65. // handle the other externally set options normally.
  66. $this->options = array_replace_recursive($this->options, $options);
  67. $this->config = $config;
  68. }
  69. /**
  70. * Copy the remote file in local.
  71. *
  72. * @param string $originUrl The origin URL
  73. * @param string $fileUrl The file URL
  74. * @param string $fileName the local filename
  75. * @param bool $progress Display the progression
  76. * @param array $options Additional context options
  77. *
  78. * @return bool true
  79. */
  80. public function copy($originUrl, $fileUrl, $fileName, $progress = true, $options = array())
  81. {
  82. return $this->get($originUrl, $fileUrl, $options, $fileName, $progress);
  83. }
  84. /**
  85. * Get the content.
  86. *
  87. * @param string $originUrl The origin URL
  88. * @param string $fileUrl The file URL
  89. * @param bool $progress Display the progression
  90. * @param array $options Additional context options
  91. *
  92. * @return bool|string The content
  93. */
  94. public function getContents($originUrl, $fileUrl, $progress = true, $options = array())
  95. {
  96. return $this->get($originUrl, $fileUrl, $options, null, $progress);
  97. }
  98. /**
  99. * Retrieve the options set in the constructor
  100. *
  101. * @return array Options
  102. */
  103. public function getOptions()
  104. {
  105. return $this->options;
  106. }
  107. /**
  108. * Merges new options
  109. *
  110. * @param array $options
  111. */
  112. public function setOptions(array $options)
  113. {
  114. $this->options = array_replace_recursive($this->options, $options);
  115. }
  116. /**
  117. * Check is disable TLS.
  118. *
  119. * @return bool
  120. */
  121. public function isTlsDisabled()
  122. {
  123. return $this->disableTls === true;
  124. }
  125. /**
  126. * Returns the headers of the last request
  127. *
  128. * @return array
  129. */
  130. public function getLastHeaders()
  131. {
  132. return $this->lastHeaders;
  133. }
  134. /**
  135. * @param array $headers array of returned headers like from getLastHeaders()
  136. * @param string $name header name (case insensitive)
  137. * @return string|null
  138. */
  139. public function findHeaderValue(array $headers, $name)
  140. {
  141. $value = null;
  142. foreach ($headers as $header) {
  143. if (preg_match('{^'.$name.':\s*(.+?)\s*$}i', $header, $match)) {
  144. $value = $match[1];
  145. } elseif (preg_match('{^HTTP/}i', $header)) {
  146. // In case of redirects, http_response_headers contains the headers of all responses
  147. // so we reset the flag when a new response is being parsed as we are only interested in the last response
  148. $value = null;
  149. }
  150. }
  151. return $value;
  152. }
  153. /**
  154. * @param array $headers array of returned headers like from getLastHeaders()
  155. * @return int|null
  156. */
  157. public function findStatusCode(array $headers)
  158. {
  159. $value = null;
  160. foreach ($headers as $header) {
  161. if (preg_match('{^HTTP/\S+ (\d+)}i', $header, $match)) {
  162. // In case of redirects, http_response_headers contains the headers of all responses
  163. // so we can not return directly and need to keep iterating
  164. $value = (int) $match[1];
  165. }
  166. }
  167. return $value;
  168. }
  169. /**
  170. * @param array $headers array of returned headers like from getLastHeaders()
  171. * @return string|null
  172. */
  173. public function findStatusMessage(array $headers)
  174. {
  175. $value = null;
  176. foreach ($headers as $header) {
  177. if (preg_match('{^HTTP/\S+ \d+}i', $header)) {
  178. // In case of redirects, http_response_headers contains the headers of all responses
  179. // so we can not return directly and need to keep iterating
  180. $value = $header;
  181. }
  182. }
  183. return $value;
  184. }
  185. /**
  186. * Get file content or copy action.
  187. *
  188. * @param string $originUrl The origin URL
  189. * @param string $fileUrl The file URL
  190. * @param array $additionalOptions context options
  191. * @param string $fileName the local filename
  192. * @param bool $progress Display the progression
  193. *
  194. * @throws TransportException|\Exception
  195. * @throws TransportException When the file could not be downloaded
  196. *
  197. * @return bool|string
  198. */
  199. protected function get($originUrl, $fileUrl, $additionalOptions = array(), $fileName = null, $progress = true)
  200. {
  201. if (strpos($originUrl, '.github.com') === (strlen($originUrl) - 11)) {
  202. $originUrl = 'github.com';
  203. }
  204. // Gitlab can be installed in a non-root context (i.e. gitlab.com/foo). When downloading archives the originUrl
  205. // is the host without the path, so we look for the registered gitlab-domains matching the host here
  206. if (
  207. $this->config
  208. && is_array($this->config->get('gitlab-domains'))
  209. && false === strpos($originUrl, '/')
  210. && !in_array($originUrl, $this->config->get('gitlab-domains'))
  211. ) {
  212. foreach ($this->config->get('gitlab-domains') as $gitlabDomain) {
  213. if (0 === strpos($gitlabDomain, $originUrl)) {
  214. $originUrl = $gitlabDomain;
  215. break;
  216. }
  217. }
  218. unset($gitlabDomain);
  219. }
  220. $this->scheme = parse_url($fileUrl, PHP_URL_SCHEME);
  221. $this->bytesMax = 0;
  222. $this->originUrl = $originUrl;
  223. $this->fileUrl = $fileUrl;
  224. $this->fileName = $fileName;
  225. $this->progress = $progress;
  226. $this->lastProgress = null;
  227. $this->retryAuthFailure = true;
  228. $this->lastHeaders = array();
  229. $this->redirects = 1; // The first request counts.
  230. // capture username/password from URL if there is one
  231. if (preg_match('{^https?://([^:/]+):([^@/]+)@([^/]+)}i', $fileUrl, $match)) {
  232. $this->io->setAuthentication($originUrl, rawurldecode($match[1]), rawurldecode($match[2]));
  233. }
  234. $tempAdditionalOptions = $additionalOptions;
  235. if (isset($tempAdditionalOptions['retry-auth-failure'])) {
  236. $this->retryAuthFailure = (bool) $tempAdditionalOptions['retry-auth-failure'];
  237. unset($tempAdditionalOptions['retry-auth-failure']);
  238. }
  239. $isRedirect = false;
  240. if (isset($tempAdditionalOptions['redirects'])) {
  241. $this->redirects = $tempAdditionalOptions['redirects'];
  242. $isRedirect = true;
  243. unset($tempAdditionalOptions['redirects']);
  244. }
  245. $options = $this->getOptionsForUrl($originUrl, $tempAdditionalOptions);
  246. unset($tempAdditionalOptions);
  247. $origFileUrl = $fileUrl;
  248. if (isset($options['github-token'])) {
  249. // only add the access_token if it is actually a github URL (in case we were redirected to S3)
  250. if (preg_match('{^https?://([a-z0-9-]+\.)*github\.com/}', $fileUrl)) {
  251. $options['http']['header'][] = 'Authorization: token '.$options['github-token'];
  252. }
  253. unset($options['github-token']);
  254. }
  255. if (isset($options['gitlab-token'])) {
  256. $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['gitlab-token'];
  257. unset($options['gitlab-token']);
  258. }
  259. if (isset($options['http'])) {
  260. $options['http']['ignore_errors'] = true;
  261. }
  262. if ($this->degradedMode && substr($fileUrl, 0, 26) === 'http://repo.packagist.org/') {
  263. // access packagist using the resolved IPv4 instead of the hostname to force IPv4 protocol
  264. $fileUrl = 'http://' . gethostbyname('repo.packagist.org') . substr($fileUrl, 20);
  265. $degradedPackagist = true;
  266. }
  267. $ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet')));
  268. $actualContextOptions = stream_context_get_options($ctx);
  269. $usingProxy = !empty($actualContextOptions['http']['proxy']) ? ' using proxy ' . $actualContextOptions['http']['proxy'] : '';
  270. $this->io->writeError((substr($origFileUrl, 0, 4) === 'http' ? 'Downloading ' : 'Reading ') . $this->stripCredentialsFromUrl($origFileUrl) . $usingProxy, true, IOInterface::DEBUG);
  271. unset($origFileUrl, $actualContextOptions);
  272. // Check for secure HTTP, but allow insecure Packagist calls to $hashed providers as file integrity is verified with sha256
  273. if ((!preg_match('{^http://(repo\.)?packagist\.org/p/}', $fileUrl) || (false === strpos($fileUrl, '$') && false === strpos($fileUrl, '%24'))) && empty($degradedPackagist) && $this->config) {
  274. $this->config->prohibitUrlByConfig($fileUrl, $this->io);
  275. }
  276. if ($this->progress && !$isRedirect) {
  277. $this->io->writeError("Downloading (<comment>connecting...</comment>)", false);
  278. }
  279. $errorMessage = '';
  280. $errorCode = 0;
  281. $result = false;
  282. set_error_handler(function ($code, $msg) use (&$errorMessage) {
  283. if ($errorMessage) {
  284. $errorMessage .= "\n";
  285. }
  286. $errorMessage .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg);
  287. return true;
  288. });
  289. try {
  290. $result = $this->getRemoteContents($originUrl, $fileUrl, $ctx, $http_response_header);
  291. if (!empty($http_response_header[0])) {
  292. $statusCode = $this->findStatusCode($http_response_header);
  293. if ($statusCode >= 400 && $this->findHeaderValue($http_response_header, 'content-type') === 'application/json') {
  294. self::outputWarnings($this->io, $originUrl, json_decode($result, true));
  295. }
  296. if (in_array($statusCode, array(401, 403)) && $this->retryAuthFailure) {
  297. $this->promptAuthAndRetry($statusCode, $this->findStatusMessage($http_response_header), null, $http_response_header);
  298. }
  299. }
  300. $contentLength = !empty($http_response_header[0]) ? $this->findHeaderValue($http_response_header, 'content-length') : null;
  301. if ($contentLength && Platform::strlen($result) < $contentLength) {
  302. // alas, this is not possible via the stream callback because STREAM_NOTIFY_COMPLETED is documented, but not implemented anywhere in PHP
  303. $e = new TransportException('Content-Length mismatch, received '.Platform::strlen($result).' bytes out of the expected '.$contentLength);
  304. $e->setHeaders($http_response_header);
  305. $e->setStatusCode($this->findStatusCode($http_response_header));
  306. $e->setResponse($result);
  307. $this->io->writeError('Content-Length mismatch, received '.Platform::strlen($result).' out of '.$contentLength.' bytes: (' . base64_encode($result).')', true, IOInterface::DEBUG);
  308. throw $e;
  309. }
  310. if (PHP_VERSION_ID < 50600 && !empty($options['ssl']['peer_fingerprint'])) {
  311. // Emulate fingerprint validation on PHP < 5.6
  312. $params = stream_context_get_params($ctx);
  313. $expectedPeerFingerprint = $options['ssl']['peer_fingerprint'];
  314. $peerFingerprint = TlsHelper::getCertificateFingerprint($params['options']['ssl']['peer_certificate']);
  315. // Constant time compare??!
  316. if ($expectedPeerFingerprint !== $peerFingerprint) {
  317. throw new TransportException('Peer fingerprint did not match');
  318. }
  319. }
  320. } catch (\Exception $e) {
  321. if ($e instanceof TransportException && !empty($http_response_header[0])) {
  322. $e->setHeaders($http_response_header);
  323. $e->setStatusCode($this->findStatusCode($http_response_header));
  324. }
  325. if ($e instanceof TransportException && $result !== false) {
  326. $e->setResponse($result);
  327. }
  328. $result = false;
  329. }
  330. if ($errorMessage && !filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN)) {
  331. $errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')';
  332. }
  333. restore_error_handler();
  334. if (isset($e) && !$this->retry) {
  335. if (!$this->degradedMode && false !== strpos($e->getMessage(), 'Operation timed out')) {
  336. $this->degradedMode = true;
  337. $this->io->writeError('');
  338. $this->io->writeError(array(
  339. '<error>'.$e->getMessage().'</error>',
  340. '<error>Retrying with degraded mode, check https://getcomposer.org/doc/articles/troubleshooting.md#degraded-mode for more info</error>',
  341. ));
  342. return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  343. }
  344. throw $e;
  345. }
  346. $statusCode = null;
  347. $contentType = null;
  348. $locationHeader = null;
  349. if (!empty($http_response_header[0])) {
  350. $statusCode = $this->findStatusCode($http_response_header);
  351. $contentType = $this->findHeaderValue($http_response_header, 'content-type');
  352. $locationHeader = $this->findHeaderValue($http_response_header, 'location');
  353. }
  354. // check for bitbucket login page asking to authenticate
  355. if ($originUrl === 'bitbucket.org'
  356. && !$this->isPublicBitBucketDownload($fileUrl)
  357. && substr($fileUrl, -4) === '.zip'
  358. && (!$locationHeader || substr($locationHeader, -4) !== '.zip')
  359. && $contentType && preg_match('{^text/html\b}i', $contentType)
  360. ) {
  361. $result = false;
  362. if ($this->retryAuthFailure) {
  363. $this->promptAuthAndRetry(401);
  364. }
  365. }
  366. // check for gitlab 404 when downloading archives
  367. if ($statusCode === 404
  368. && $this->config && in_array($originUrl, $this->config->get('gitlab-domains'), true)
  369. && false !== strpos($fileUrl, 'archive.zip')
  370. ) {
  371. $result = false;
  372. if ($this->retryAuthFailure) {
  373. $this->promptAuthAndRetry(401);
  374. }
  375. }
  376. // handle 3xx redirects, 304 Not Modified is excluded
  377. $hasFollowedRedirect = false;
  378. if ($statusCode >= 300 && $statusCode <= 399 && $statusCode !== 304 && $this->redirects < $this->maxRedirects) {
  379. $hasFollowedRedirect = true;
  380. $result = $this->handleRedirect($http_response_header, $additionalOptions, $result);
  381. }
  382. // fail 4xx and 5xx responses and capture the response
  383. if ($statusCode && $statusCode >= 400 && $statusCode <= 599) {
  384. if (!$this->retry) {
  385. if ($this->progress && !$this->retry && !$isRedirect) {
  386. $this->io->overwriteError("Downloading (<error>failed</error>)", false);
  387. }
  388. $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded ('.$http_response_header[0].')', $statusCode);
  389. $e->setHeaders($http_response_header);
  390. $e->setResponse($result);
  391. $e->setStatusCode($statusCode);
  392. throw $e;
  393. }
  394. $result = false;
  395. }
  396. if ($this->progress && !$this->retry && !$isRedirect) {
  397. $this->io->overwriteError("Downloading (".($result === false ? '<error>failed</error>' : '<comment>100%</comment>').")", false);
  398. }
  399. // decode gzip
  400. if ($result && extension_loaded('zlib') && substr($fileUrl, 0, 4) === 'http' && !$hasFollowedRedirect) {
  401. $contentEncoding = $this->findHeaderValue($http_response_header, 'content-encoding');
  402. $decode = $contentEncoding && 'gzip' === strtolower($contentEncoding);
  403. if ($decode) {
  404. try {
  405. if (PHP_VERSION_ID >= 50400) {
  406. $result = zlib_decode($result);
  407. } else {
  408. // work around issue with gzuncompress & co that do not work with all gzip checksums
  409. $result = file_get_contents('compress.zlib://data:application/octet-stream;base64,'.base64_encode($result));
  410. }
  411. if (!$result) {
  412. throw new TransportException('Failed to decode zlib stream');
  413. }
  414. } catch (\Exception $e) {
  415. if ($this->degradedMode) {
  416. throw $e;
  417. }
  418. $this->degradedMode = true;
  419. $this->io->writeError(array(
  420. '',
  421. '<error>Failed to decode response: '.$e->getMessage().'</error>',
  422. '<error>Retrying with degraded mode, check https://getcomposer.org/doc/articles/troubleshooting.md#degraded-mode for more info</error>',
  423. ));
  424. return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  425. }
  426. }
  427. }
  428. // handle copy command if download was successful
  429. if (false !== $result && null !== $fileName && !$isRedirect) {
  430. if ('' === $result) {
  431. throw new TransportException('"'.$this->fileUrl.'" appears broken, and returned an empty 200 response');
  432. }
  433. $errorMessage = '';
  434. set_error_handler(function ($code, $msg) use (&$errorMessage) {
  435. if ($errorMessage) {
  436. $errorMessage .= "\n";
  437. }
  438. $errorMessage .= preg_replace('{^file_put_contents\(.*?\): }', '', $msg);
  439. return true;
  440. });
  441. $result = (bool) file_put_contents($fileName, $result);
  442. restore_error_handler();
  443. if (false === $result) {
  444. throw new TransportException('The "'.$this->fileUrl.'" file could not be written to '.$fileName.': '.$errorMessage);
  445. }
  446. }
  447. // Handle SSL cert match issues
  448. if (false === $result && false !== strpos($errorMessage, 'Peer certificate') && PHP_VERSION_ID < 50600) {
  449. // Certificate name error, PHP doesn't support subjectAltName on PHP < 5.6
  450. // The procedure to handle sAN for older PHP's is:
  451. //
  452. // 1. Open socket to remote server and fetch certificate (disabling peer
  453. // validation because PHP errors without giving up the certificate.)
  454. //
  455. // 2. Verifying the domain in the URL against the names in the sAN field.
  456. // If there is a match record the authority [host/port], certificate
  457. // common name, and certificate fingerprint.
  458. //
  459. // 3. Retry the original request but changing the CN_match parameter to
  460. // the common name extracted from the certificate in step 2.
  461. //
  462. // 4. To prevent any attempt at being hoodwinked by switching the
  463. // certificate between steps 2 and 3 the fingerprint of the certificate
  464. // presented in step 3 is compared against the one recorded in step 2.
  465. if (CaBundle::isOpensslParseSafe()) {
  466. $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options);
  467. if ($certDetails) {
  468. $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails;
  469. $this->retry = true;
  470. }
  471. } else {
  472. $this->io->writeError('');
  473. $this->io->writeError(sprintf(
  474. '<error>Your version of PHP, %s, is affected by CVE-2013-6420 and cannot safely perform certificate validation, we strongly suggest you upgrade.</error>',
  475. PHP_VERSION
  476. ));
  477. }
  478. }
  479. if ($this->retry) {
  480. $this->retry = false;
  481. $result = $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  482. if ($this->storeAuth && $this->config) {
  483. $authHelper = new AuthHelper($this->io, $this->config);
  484. $authHelper->storeAuth($this->originUrl, $this->storeAuth);
  485. $this->storeAuth = false;
  486. }
  487. return $result;
  488. }
  489. if (false === $result) {
  490. $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded: '.$errorMessage, $errorCode);
  491. if (!empty($http_response_header[0])) {
  492. $e->setHeaders($http_response_header);
  493. }
  494. if (!$this->degradedMode && false !== strpos($e->getMessage(), 'Operation timed out')) {
  495. $this->degradedMode = true;
  496. $this->io->writeError('');
  497. $this->io->writeError(array(
  498. '<error>'.$e->getMessage().'</error>',
  499. '<error>Retrying with degraded mode, check https://getcomposer.org/doc/articles/troubleshooting.md#degraded-mode for more info</error>',
  500. ));
  501. return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
  502. }
  503. throw $e;
  504. }
  505. if (!empty($http_response_header[0])) {
  506. $this->lastHeaders = $http_response_header;
  507. }
  508. return $result;
  509. }
  510. /**
  511. * Get contents of remote URL.
  512. *
  513. * @param string $originUrl The origin URL
  514. * @param string $fileUrl The file URL
  515. * @param resource $context The stream context
  516. *
  517. * @return string|false The response contents or false on failure
  518. */
  519. protected function getRemoteContents($originUrl, $fileUrl, $context, array &$responseHeaders = null)
  520. {
  521. try {
  522. $e = null;
  523. $result = file_get_contents($fileUrl, false, $context);
  524. } catch (\Throwable $e) {
  525. } catch (\Exception $e) {
  526. }
  527. $responseHeaders = isset($http_response_header) ? $http_response_header : array();
  528. if (null !== $e) {
  529. throw $e;
  530. }
  531. return $result;
  532. }
  533. /**
  534. * Get notification action.
  535. *
  536. * @param int $notificationCode The notification code
  537. * @param int $severity The severity level
  538. * @param string $message The message
  539. * @param int $messageCode The message code
  540. * @param int $bytesTransferred The loaded size
  541. * @param int $bytesMax The total size
  542. * @throws TransportException
  543. */
  544. protected function callbackGet($notificationCode, $severity, $message, $messageCode, $bytesTransferred, $bytesMax)
  545. {
  546. switch ($notificationCode) {
  547. case STREAM_NOTIFY_FAILURE:
  548. if (400 === $messageCode) {
  549. // This might happen if your host is secured by ssl client certificate authentication
  550. // but you do not send an appropriate certificate
  551. throw new TransportException("The '" . $this->fileUrl . "' URL could not be accessed: " . $message, $messageCode);
  552. }
  553. break;
  554. case STREAM_NOTIFY_FILE_SIZE_IS:
  555. $this->bytesMax = $bytesMax;
  556. break;
  557. case STREAM_NOTIFY_PROGRESS:
  558. if ($this->bytesMax > 0 && $this->progress) {
  559. $progression = min(100, round($bytesTransferred / $this->bytesMax * 100));
  560. if ((0 === $progression % 5) && 100 !== $progression && $progression !== $this->lastProgress) {
  561. $this->lastProgress = $progression;
  562. $this->io->overwriteError("Downloading (<comment>$progression%</comment>)", false);
  563. }
  564. }
  565. break;
  566. default:
  567. break;
  568. }
  569. }
  570. protected function promptAuthAndRetry($httpStatus, $reason = null, $warning = null, $headers = array())
  571. {
  572. if ($this->config && in_array($this->originUrl, $this->config->get('github-domains'), true)) {
  573. $gitHubUtil = new GitHub($this->io, $this->config, null);
  574. $message = "\n";
  575. $rateLimited = $gitHubUtil->isRateLimited($headers);
  576. if ($rateLimited) {
  577. $rateLimit = $gitHubUtil->getRateLimit($headers);
  578. if ($this->io->hasAuthentication($this->originUrl)) {
  579. $message = 'Review your configured GitHub OAuth token or enter a new one to go over the API rate limit.';
  580. } else {
  581. $message = 'Create a GitHub OAuth token to go over the API rate limit.';
  582. }
  583. $message = sprintf(
  584. 'GitHub API limit (%d calls/hr) is exhausted, could not fetch '.$this->fileUrl.'. '.$message.' You can also wait until %s for the rate limit to reset.',
  585. $rateLimit['limit'],
  586. $rateLimit['reset']
  587. )."\n";
  588. } else {
  589. $message .= 'Could not fetch '.$this->fileUrl.', please ';
  590. if ($this->io->hasAuthentication($this->originUrl)) {
  591. $message .= 'review your configured GitHub OAuth token or enter a new one to access private repos';
  592. } else {
  593. $message .= 'create a GitHub OAuth token to access private repos';
  594. }
  595. }
  596. if (!$gitHubUtil->authorizeOAuth($this->originUrl)
  597. && (!$this->io->isInteractive() || !$gitHubUtil->authorizeOAuthInteractively($this->originUrl, $message))
  598. ) {
  599. throw new TransportException('Could not authenticate against '.$this->originUrl, 401);
  600. }
  601. } elseif ($this->config && in_array($this->originUrl, $this->config->get('gitlab-domains'), true)) {
  602. $message = "\n".'Could not fetch '.$this->fileUrl.', enter your ' . $this->originUrl . ' credentials ' .($httpStatus === 401 ? 'to access private repos' : 'to go over the API rate limit');
  603. $gitLabUtil = new GitLab($this->io, $this->config, null);
  604. if ($this->io->hasAuthentication($this->originUrl) && ($auth = $this->io->getAuthentication($this->originUrl)) && in_array($auth['password'], array('gitlab-ci-token', 'private-token'), true)) {
  605. throw new TransportException("Invalid credentials for '" . $this->fileUrl . "', aborting.", $httpStatus);
  606. }
  607. if (!$gitLabUtil->authorizeOAuth($this->originUrl)
  608. && (!$this->io->isInteractive() || !$gitLabUtil->authorizeOAuthInteractively($this->scheme, $this->originUrl, $message))
  609. ) {
  610. throw new TransportException('Could not authenticate against '.$this->originUrl, 401);
  611. }
  612. } elseif ($this->config && $this->originUrl === 'bitbucket.org') {
  613. $askForOAuthToken = true;
  614. if ($this->io->hasAuthentication($this->originUrl)) {
  615. $auth = $this->io->getAuthentication($this->originUrl);
  616. if ($auth['username'] !== 'x-token-auth') {
  617. $bitbucketUtil = new Bitbucket($this->io, $this->config);
  618. $accessToken = $bitbucketUtil->requestToken($this->originUrl, $auth['username'], $auth['password']);
  619. if (!empty($accessToken)) {
  620. $this->io->setAuthentication($this->originUrl, 'x-token-auth', $accessToken);
  621. $askForOAuthToken = false;
  622. }
  623. } else {
  624. throw new TransportException('Could not authenticate against ' . $this->originUrl, 401);
  625. }
  626. }
  627. if ($askForOAuthToken) {
  628. $message = "\n".'Could not fetch ' . $this->fileUrl . ', please create a bitbucket OAuth token to ' . (($httpStatus === 401 || $httpStatus === 403) ? 'access private repos' : 'go over the API rate limit');
  629. $bitBucketUtil = new Bitbucket($this->io, $this->config);
  630. if (! $bitBucketUtil->authorizeOAuth($this->originUrl)
  631. && (! $this->io->isInteractive() || !$bitBucketUtil->authorizeOAuthInteractively($this->originUrl, $message))
  632. ) {
  633. throw new TransportException('Could not authenticate against ' . $this->originUrl, 401);
  634. }
  635. }
  636. } else {
  637. // 404s are only handled for github
  638. if ($httpStatus === 404) {
  639. return;
  640. }
  641. // fail if the console is not interactive
  642. if (!$this->io->isInteractive()) {
  643. if ($httpStatus === 401) {
  644. $message = "The '" . $this->fileUrl . "' URL required authentication.\nYou must be using the interactive console to authenticate";
  645. }
  646. if ($httpStatus === 403) {
  647. $message = "The '" . $this->fileUrl . "' URL could not be accessed: " . $reason;
  648. }
  649. throw new TransportException($message, $httpStatus);
  650. }
  651. // fail if we already have auth
  652. if ($this->io->hasAuthentication($this->originUrl)) {
  653. throw new TransportException("Invalid credentials for '" . $this->fileUrl . "', aborting.", $httpStatus);
  654. }
  655. $this->io->writeError(' Authentication required (<info>'.$this->originUrl.'</info>):');
  656. $username = $this->io->ask(' Username: ');
  657. $password = $this->io->askAndHideAnswer(' Password: ');
  658. $this->io->setAuthentication($this->originUrl, $username, $password);
  659. $this->storeAuth = $this->config->get('store-auths');
  660. }
  661. $this->retry = true;
  662. throw new TransportException('RETRY');
  663. }
  664. protected function getOptionsForUrl($originUrl, $additionalOptions)
  665. {
  666. $tlsOptions = array();
  667. // Setup remaining TLS options - the matching may need monitoring, esp. www vs none in CN
  668. if ($this->disableTls === false && PHP_VERSION_ID < 50600 && !stream_is_local($this->fileUrl)) {
  669. $host = parse_url($this->fileUrl, PHP_URL_HOST);
  670. if (PHP_VERSION_ID < 50304) {
  671. // PHP < 5.3.4 does not support follow_location, for those people
  672. // do some really nasty hard coded transformations. These will
  673. // still breakdown if the site redirects to a domain we don't
  674. // expect.
  675. if ($host === 'github.com' || $host === 'api.github.com') {
  676. $host = '*.github.com';
  677. }
  678. }
  679. $tlsOptions['ssl']['CN_match'] = $host;
  680. $tlsOptions['ssl']['SNI_server_name'] = $host;
  681. $urlAuthority = $this->getUrlAuthority($this->fileUrl);
  682. if (isset($this->peerCertificateMap[$urlAuthority])) {
  683. // Handle subjectAltName on lesser PHP's.
  684. $certMap = $this->peerCertificateMap[$urlAuthority];
  685. $this->io->writeError('', true, IOInterface::DEBUG);
  686. $this->io->writeError(sprintf(
  687. 'Using <info>%s</info> as CN for subjectAltName enabled host <info>%s</info>',
  688. $certMap['cn'],
  689. $urlAuthority
  690. ), true, IOInterface::DEBUG);
  691. $tlsOptions['ssl']['CN_match'] = $certMap['cn'];
  692. $tlsOptions['ssl']['peer_fingerprint'] = $certMap['fp'];
  693. } elseif (!CaBundle::isOpensslParseSafe() && $host === 'repo.packagist.org') {
  694. // handle subjectAltName for packagist.org's repo domain on very old PHPs
  695. $tlsOptions['ssl']['CN_match'] = 'packagist.org';
  696. }
  697. }
  698. $headers = array();
  699. if (extension_loaded('zlib')) {
  700. $headers[] = 'Accept-Encoding: gzip';
  701. }
  702. $options = array_replace_recursive($this->options, $tlsOptions, $additionalOptions);
  703. if (!$this->degradedMode) {
  704. // degraded mode disables HTTP/1.1 which causes issues with some bad
  705. // proxies/software due to the use of chunked encoding
  706. $options['http']['protocol_version'] = 1.1;
  707. $headers[] = 'Connection: close';
  708. }
  709. if ($this->io->hasAuthentication($originUrl)) {
  710. $authenticationDisplayMessage = null;
  711. $auth = $this->io->getAuthentication($originUrl);
  712. if ('github.com' === $originUrl && 'x-oauth-basic' === $auth['password']) {
  713. $options['github-token'] = $auth['username'];
  714. $authenticationDisplayMessage = 'Using GitHub token authentication';
  715. } elseif ($this->config && in_array($originUrl, $this->config->get('gitlab-domains'), true)) {
  716. if ($auth['password'] === 'oauth2') {
  717. $headers[] = 'Authorization: Bearer '.$auth['username'];
  718. $authenticationDisplayMessage = 'Using GitLab OAuth token authentication';
  719. } elseif ($auth['password'] === 'private-token' || $auth['password'] === 'gitlab-ci-token') {
  720. $headers[] = 'PRIVATE-TOKEN: '.$auth['username'];
  721. $authenticationDisplayMessage = 'Using GitLab private token authentication';
  722. }
  723. } elseif ('bitbucket.org' === $originUrl
  724. && $this->fileUrl !== Bitbucket::OAUTH2_ACCESS_TOKEN_URL && 'x-token-auth' === $auth['username']
  725. ) {
  726. if (!$this->isPublicBitBucketDownload($this->fileUrl)) {
  727. $headers[] = 'Authorization: Bearer ' . $auth['password'];
  728. $authenticationDisplayMessage = 'Using Bitbucket OAuth token authentication';
  729. }
  730. } elseif ($auth['password'] === 'bearer' ) {
  731. $headers[] = 'Authorization: Bearer '.$auth['username'];
  732. } else {
  733. $authStr = base64_encode($auth['username'] . ':' . $auth['password']);
  734. $headers[] = 'Authorization: Basic '.$authStr;
  735. $authenticationDisplayMessage = 'Using HTTP basic authentication with username "' . $auth['username'] . '"';
  736. }
  737. if ($authenticationDisplayMessage && !in_array($originUrl, $this->displayedOriginAuthentications, true)) {
  738. $this->io->writeError($authenticationDisplayMessage, true, IOInterface::DEBUG);
  739. $this->displayedOriginAuthentications[] = $originUrl;
  740. }
  741. }
  742. $options['http']['follow_location'] = 0;
  743. if (isset($options['http']['header']) && !is_array($options['http']['header'])) {
  744. $options['http']['header'] = explode("\r\n", trim($options['http']['header'], "\r\n"));
  745. }
  746. foreach ($headers as $header) {
  747. $options['http']['header'][] = $header;
  748. }
  749. return $options;
  750. }
  751. private function handleRedirect(array $http_response_header, array $additionalOptions, $result)
  752. {
  753. if ($locationHeader = $this->findHeaderValue($http_response_header, 'location')) {
  754. if (parse_url($locationHeader, PHP_URL_SCHEME)) {
  755. // Absolute URL; e.g. https://example.com/composer
  756. $targetUrl = $locationHeader;
  757. } elseif (parse_url($locationHeader, PHP_URL_HOST)) {
  758. // Scheme relative; e.g. //example.com/foo
  759. $targetUrl = $this->scheme.':'.$locationHeader;
  760. } elseif ('/' === $locationHeader[0]) {
  761. // Absolute path; e.g. /foo
  762. $urlHost = parse_url($this->fileUrl, PHP_URL_HOST);
  763. // Replace path using hostname as an anchor.
  764. $targetUrl = preg_replace('{^(.+(?://|@)'.preg_quote($urlHost).'(?::\d+)?)(?:[/\?].*)?$}', '\1'.$locationHeader, $this->fileUrl);
  765. } else {
  766. // Relative path; e.g. foo
  767. // This actually differs from PHP which seems to add duplicate slashes.
  768. $targetUrl = preg_replace('{^(.+/)[^/?]*(?:\?.*)?$}', '\1'.$locationHeader, $this->fileUrl);
  769. }
  770. }
  771. if (!empty($targetUrl)) {
  772. $this->redirects++;
  773. $this->io->writeError('', true, IOInterface::DEBUG);
  774. $this->io->writeError(sprintf('Following redirect (%u) %s', $this->redirects, $this->stripCredentialsFromUrl($targetUrl)), true, IOInterface::DEBUG);
  775. $additionalOptions['redirects'] = $this->redirects;
  776. return $this->get(parse_url($targetUrl, PHP_URL_HOST), $targetUrl, $additionalOptions, $this->fileName, $this->progress);
  777. }
  778. if (!$this->retry) {
  779. $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded, got redirect without Location ('.$http_response_header[0].')');
  780. $e->setHeaders($http_response_header);
  781. $e->setResponse($result);
  782. throw $e;
  783. }
  784. return false;
  785. }
  786. /**
  787. * @param array $options
  788. *
  789. * @return array
  790. */
  791. private function getTlsDefaults(array $options)
  792. {
  793. $ciphers = implode(':', array(
  794. 'ECDHE-RSA-AES128-GCM-SHA256',
  795. 'ECDHE-ECDSA-AES128-GCM-SHA256',
  796. 'ECDHE-RSA-AES256-GCM-SHA384',
  797. 'ECDHE-ECDSA-AES256-GCM-SHA384',
  798. 'DHE-RSA-AES128-GCM-SHA256',
  799. 'DHE-DSS-AES128-GCM-SHA256',
  800. 'kEDH+AESGCM',
  801. 'ECDHE-RSA-AES128-SHA256',
  802. 'ECDHE-ECDSA-AES128-SHA256',
  803. 'ECDHE-RSA-AES128-SHA',
  804. 'ECDHE-ECDSA-AES128-SHA',
  805. 'ECDHE-RSA-AES256-SHA384',
  806. 'ECDHE-ECDSA-AES256-SHA384',
  807. 'ECDHE-RSA-AES256-SHA',
  808. 'ECDHE-ECDSA-AES256-SHA',
  809. 'DHE-RSA-AES128-SHA256',
  810. 'DHE-RSA-AES128-SHA',
  811. 'DHE-DSS-AES128-SHA256',
  812. 'DHE-RSA-AES256-SHA256',
  813. 'DHE-DSS-AES256-SHA',
  814. 'DHE-RSA-AES256-SHA',
  815. 'AES128-GCM-SHA256',
  816. 'AES256-GCM-SHA384',
  817. 'AES128-SHA256',
  818. 'AES256-SHA256',
  819. 'AES128-SHA',
  820. 'AES256-SHA',
  821. 'AES',
  822. 'CAMELLIA',
  823. 'DES-CBC3-SHA',
  824. '!aNULL',
  825. '!eNULL',
  826. '!EXPORT',
  827. '!DES',
  828. '!RC4',
  829. '!MD5',
  830. '!PSK',
  831. '!aECDH',
  832. '!EDH-DSS-DES-CBC3-SHA',
  833. '!EDH-RSA-DES-CBC3-SHA',
  834. '!KRB5-DES-CBC3-SHA',
  835. ));
  836. /**
  837. * CN_match and SNI_server_name are only known once a URL is passed.
  838. * They will be set in the getOptionsForUrl() method which receives a URL.
  839. *
  840. * cafile or capath can be overridden by passing in those options to constructor.
  841. */
  842. $defaults = array(
  843. 'ssl' => array(
  844. 'ciphers' => $ciphers,
  845. 'verify_peer' => true,
  846. 'verify_depth' => 7,
  847. 'SNI_enabled' => true,
  848. 'capture_peer_cert' => true,
  849. ),
  850. );
  851. if (isset($options['ssl'])) {
  852. $defaults['ssl'] = array_replace_recursive($defaults['ssl'], $options['ssl']);
  853. }
  854. $caBundleLogger = $this->io instanceof LoggerInterface ? $this->io : null;
  855. /**
  856. * Attempt to find a local cafile or throw an exception if none pre-set
  857. * The user may go download one if this occurs.
  858. */
  859. if (!isset($defaults['ssl']['cafile']) && !isset($defaults['ssl']['capath'])) {
  860. $result = CaBundle::getSystemCaRootBundlePath($caBundleLogger);
  861. if (is_dir($result)) {
  862. $defaults['ssl']['capath'] = $result;
  863. } else {
  864. $defaults['ssl']['cafile'] = $result;
  865. }
  866. }
  867. if (isset($defaults['ssl']['cafile']) && (!is_readable($defaults['ssl']['cafile']) || !CaBundle::validateCaFile($defaults['ssl']['cafile'], $caBundleLogger))) {
  868. throw new TransportException('The configured cafile was not valid or could not be read.');
  869. }
  870. if (isset($defaults['ssl']['capath']) && (!is_dir($defaults['ssl']['capath']) || !is_readable($defaults['ssl']['capath']))) {
  871. throw new TransportException('The configured capath was not valid or could not be read.');
  872. }
  873. /**
  874. * Disable TLS compression to prevent CRIME attacks where supported.
  875. */
  876. if (PHP_VERSION_ID >= 50413) {
  877. $defaults['ssl']['disable_compression'] = true;
  878. }
  879. return $defaults;
  880. }
  881. /**
  882. * Fetch certificate common name and fingerprint for validation of SAN.
  883. *
  884. * @todo Remove when PHP 5.6 is minimum supported version.
  885. */
  886. private function getCertificateCnAndFp($url, $options)
  887. {
  888. if (PHP_VERSION_ID >= 50600) {
  889. throw new \BadMethodCallException(sprintf(
  890. '%s must not be used on PHP >= 5.6',
  891. __METHOD__
  892. ));
  893. }
  894. $context = StreamContextFactory::getContext($url, $options, array('options' => array(
  895. 'ssl' => array(
  896. 'capture_peer_cert' => true,
  897. 'verify_peer' => false, // Yes this is fucking insane! But PHP is lame.
  898. ), ),
  899. ));
  900. // Ideally this would just use stream_socket_client() to avoid sending a
  901. // HTTP request but that does not capture the certificate.
  902. if (false === $handle = @fopen($url, 'rb', false, $context)) {
  903. return;
  904. }
  905. // Close non authenticated connection without reading any content.
  906. fclose($handle);
  907. $handle = null;
  908. $params = stream_context_get_params($context);
  909. if (!empty($params['options']['ssl']['peer_certificate'])) {
  910. $peerCertificate = $params['options']['ssl']['peer_certificate'];
  911. if (TlsHelper::checkCertificateHost($peerCertificate, parse_url($url, PHP_URL_HOST), $commonName)) {
  912. return array(
  913. 'cn' => $commonName,
  914. 'fp' => TlsHelper::getCertificateFingerprint($peerCertificate),
  915. );
  916. }
  917. }
  918. }
  919. private function getUrlAuthority($url)
  920. {
  921. $defaultPorts = array(
  922. 'ftp' => 21,
  923. 'http' => 80,
  924. 'https' => 443,
  925. 'ssh2.sftp' => 22,
  926. 'ssh2.scp' => 22,
  927. );
  928. $scheme = parse_url($url, PHP_URL_SCHEME);
  929. if (!isset($defaultPorts[$scheme])) {
  930. throw new \InvalidArgumentException(sprintf(
  931. 'Could not get default port for unknown scheme: %s',
  932. $scheme
  933. ));
  934. }
  935. $defaultPort = $defaultPorts[$scheme];
  936. $port = parse_url($url, PHP_URL_PORT) ?: $defaultPort;
  937. return parse_url($url, PHP_URL_HOST).':'.$port;
  938. }
  939. /**
  940. * @link https://github.com/composer/composer/issues/5584
  941. *
  942. * @param string $urlToBitBucketFile URL to a file at bitbucket.org.
  943. *
  944. * @return bool Whether the given URL is a public BitBucket download which requires no authentication.
  945. */
  946. private function isPublicBitBucketDownload($urlToBitBucketFile)
  947. {
  948. $domain = parse_url($urlToBitBucketFile, PHP_URL_HOST);
  949. if (strpos($domain, 'bitbucket.org') === false) {
  950. // Bitbucket downloads are hosted on amazonaws.
  951. // We do not need to authenticate there at all
  952. return true;
  953. }
  954. $path = parse_url($urlToBitBucketFile, PHP_URL_PATH);
  955. // Path for a public download follows this pattern /{user}/{repo}/downloads/{whatever}
  956. // {@link https://blog.bitbucket.org/2009/04/12/new-feature-downloads/}
  957. $pathParts = explode('/', $path);
  958. return count($pathParts) >= 4 && $pathParts[3] == 'downloads';
  959. }
  960. public static function outputWarnings(IOInterface $io, $url, $data)
  961. {
  962. foreach (array('warning', 'info') as $type) {
  963. if (empty($data[$type])) {
  964. continue;
  965. }
  966. if (!empty($data[$type . '-versions'])) {
  967. $versionParser = new VersionParser();
  968. $constraint = $versionParser->parseConstraints($data[$type . '-versions']);
  969. $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion()));
  970. if (!$constraint->matches($composer)) {
  971. continue;
  972. }
  973. }
  974. $io->writeError('<'.$type.'>'.ucfirst($type).' from '.$url.': '.$data[$type].'</'.$type.'>');
  975. }
  976. }
  977. public static function getOrigin($urlOrPath)
  978. {
  979. $hostPort = parse_url($urlOrPath, PHP_URL_HOST);
  980. if (!$hostPort) {
  981. return $urlOrPath;
  982. }
  983. if (parse_url($urlOrPath, PHP_URL_PORT)) {
  984. $hostPort .= ':'.parse_url($urlOrPath, PHP_URL_PORT);
  985. }
  986. return $hostPort;
  987. }
  988. private function stripCredentialsFromUrl($url)
  989. {
  990. // GitHub repository rename result in redirect locations containing the access_token as GET parameter
  991. // e.g. https://api.github.com/repositories/9999999999?access_token=github_token
  992. return preg_replace('{([&?]access_token=)[^&]+}', '$1***', $url);
  993. }
  994. }