HttpClientTrait.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpClient;
  11. use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
  12. use Symfony\Component\HttpClient\Exception\TransportException;
  13. /**
  14. * Provides the common logic from writing HttpClientInterface implementations.
  15. *
  16. * All private methods are static to prevent implementers from creating memory leaks via circular references.
  17. *
  18. * @author Nicolas Grekas <p@tchwork.com>
  19. */
  20. trait HttpClientTrait
  21. {
  22. private static $CHUNK_SIZE = 16372;
  23. /**
  24. * {@inheritdoc}
  25. */
  26. public function withOptions(array $options): self
  27. {
  28. $clone = clone $this;
  29. $clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
  30. return $clone;
  31. }
  32. /**
  33. * Validates and normalizes method, URL and options, and merges them with defaults.
  34. *
  35. * @throws InvalidArgumentException When a not-supported option is found
  36. */
  37. private static function prepareRequest(?string $method, ?string $url, array $options, array $defaultOptions = [], bool $allowExtraOptions = false): array
  38. {
  39. if (null !== $method) {
  40. if (\strlen($method) !== strspn($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) {
  41. throw new InvalidArgumentException(sprintf('Invalid HTTP method "%s", only uppercase letters are accepted.', $method));
  42. }
  43. if (!$method) {
  44. throw new InvalidArgumentException('The HTTP method cannot be empty.');
  45. }
  46. }
  47. $options = self::mergeDefaultOptions($options, $defaultOptions, $allowExtraOptions);
  48. $buffer = $options['buffer'] ?? true;
  49. if ($buffer instanceof \Closure) {
  50. $options['buffer'] = static function (array $headers) use ($buffer) {
  51. if (!\is_bool($buffer = $buffer($headers))) {
  52. if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
  53. throw new \LogicException(sprintf('The closure passed as option "buffer" must return bool or stream resource, got "%s".', get_debug_type($buffer)));
  54. }
  55. if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
  56. throw new \LogicException(sprintf('The stream returned by the closure passed as option "buffer" must be writeable, got mode "%s".', $bufferInfo['mode']));
  57. }
  58. }
  59. return $buffer;
  60. };
  61. } elseif (!\is_bool($buffer)) {
  62. if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
  63. throw new InvalidArgumentException(sprintf('Option "buffer" must be bool, stream resource or Closure, "%s" given.', get_debug_type($buffer)));
  64. }
  65. if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
  66. throw new InvalidArgumentException(sprintf('The stream in option "buffer" must be writeable, mode "%s" given.', $bufferInfo['mode']));
  67. }
  68. }
  69. if (isset($options['json'])) {
  70. if (isset($options['body']) && '' !== $options['body']) {
  71. throw new InvalidArgumentException('Define either the "json" or the "body" option, setting both is not supported.');
  72. }
  73. $options['body'] = self::jsonEncode($options['json']);
  74. unset($options['json']);
  75. if (!isset($options['normalized_headers']['content-type'])) {
  76. $options['normalized_headers']['content-type'] = ['Content-Type: application/json'];
  77. }
  78. }
  79. if (!isset($options['normalized_headers']['accept'])) {
  80. $options['normalized_headers']['accept'] = ['Accept: */*'];
  81. }
  82. if (isset($options['body'])) {
  83. $options['body'] = self::normalizeBody($options['body']);
  84. if (\is_string($options['body'])
  85. && (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
  86. && ('' !== $h || '' !== $options['body'])
  87. ) {
  88. if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) {
  89. unset($options['normalized_headers']['transfer-encoding']);
  90. $options['body'] = self::dechunk($options['body']);
  91. }
  92. $options['normalized_headers']['content-length'] = [substr_replace($h ?: 'Content-Length: ', \strlen($options['body']), 16)];
  93. }
  94. }
  95. if (isset($options['peer_fingerprint'])) {
  96. $options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']);
  97. }
  98. // Validate on_progress
  99. if (isset($options['on_progress']) && !\is_callable($onProgress = $options['on_progress'])) {
  100. throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
  101. }
  102. if (\is_array($options['auth_basic'] ?? null)) {
  103. $count = \count($options['auth_basic']);
  104. if ($count <= 0 || $count > 2) {
  105. throw new InvalidArgumentException(sprintf('Option "auth_basic" must contain 1 or 2 elements, "%s" given.', $count));
  106. }
  107. $options['auth_basic'] = implode(':', $options['auth_basic']);
  108. }
  109. if (!\is_string($options['auth_basic'] ?? '')) {
  110. throw new InvalidArgumentException(sprintf('Option "auth_basic" must be string or an array, "%s" given.', get_debug_type($options['auth_basic'])));
  111. }
  112. if (isset($options['auth_bearer'])) {
  113. if (!\is_string($options['auth_bearer'])) {
  114. throw new InvalidArgumentException(sprintf('Option "auth_bearer" must be a string, "%s" given.', get_debug_type($options['auth_bearer'])));
  115. }
  116. if (preg_match('{[^\x21-\x7E]}', $options['auth_bearer'])) {
  117. throw new InvalidArgumentException('Invalid character found in option "auth_bearer": '.json_encode($options['auth_bearer']).'.');
  118. }
  119. }
  120. if (isset($options['auth_basic'], $options['auth_bearer'])) {
  121. throw new InvalidArgumentException('Define either the "auth_basic" or the "auth_bearer" option, setting both is not supported.');
  122. }
  123. if (null !== $url) {
  124. // Merge auth with headers
  125. if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
  126. $options['normalized_headers']['authorization'] = ['Authorization: Basic '.base64_encode($options['auth_basic'])];
  127. }
  128. // Merge bearer with headers
  129. if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
  130. $options['normalized_headers']['authorization'] = ['Authorization: Bearer '.$options['auth_bearer']];
  131. }
  132. unset($options['auth_basic'], $options['auth_bearer']);
  133. // Parse base URI
  134. if (\is_string($options['base_uri'])) {
  135. $options['base_uri'] = self::parseUrl($options['base_uri']);
  136. }
  137. // Validate and resolve URL
  138. $url = self::parseUrl($url, $options['query']);
  139. $url = self::resolveUrl($url, $options['base_uri'], $defaultOptions['query'] ?? []);
  140. }
  141. // Finalize normalization of options
  142. $options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
  143. if (0 > $options['timeout'] = (float) ($options['timeout'] ?? \ini_get('default_socket_timeout'))) {
  144. $options['timeout'] = 172800.0; // 2 days
  145. }
  146. $options['max_duration'] = isset($options['max_duration']) ? (float) $options['max_duration'] : 0;
  147. $options['headers'] = array_merge(...array_values($options['normalized_headers']));
  148. return [$url, $options];
  149. }
  150. /**
  151. * @throws InvalidArgumentException When an invalid option is found
  152. */
  153. private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array
  154. {
  155. $options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []);
  156. if ($defaultOptions['headers'] ?? false) {
  157. $options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']);
  158. }
  159. $options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]);
  160. if ($resolve = $options['resolve'] ?? false) {
  161. $options['resolve'] = [];
  162. foreach ($resolve as $k => $v) {
  163. $options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v;
  164. }
  165. }
  166. // Option "query" is never inherited from defaults
  167. $options['query'] = $options['query'] ?? [];
  168. $options += $defaultOptions;
  169. if (isset(self::$emptyDefaults)) {
  170. foreach (self::$emptyDefaults as $k => $v) {
  171. if (!isset($options[$k])) {
  172. $options[$k] = $v;
  173. }
  174. }
  175. }
  176. if (isset($defaultOptions['extra'])) {
  177. $options['extra'] += $defaultOptions['extra'];
  178. }
  179. if ($resolve = $defaultOptions['resolve'] ?? false) {
  180. foreach ($resolve as $k => $v) {
  181. $options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v];
  182. }
  183. }
  184. if ($allowExtraOptions || !$defaultOptions) {
  185. return $options;
  186. }
  187. // Look for unsupported options
  188. foreach ($options as $name => $v) {
  189. if (\array_key_exists($name, $defaultOptions) || 'normalized_headers' === $name) {
  190. continue;
  191. }
  192. if ('auth_ntlm' === $name) {
  193. if (!\extension_loaded('curl')) {
  194. $msg = 'try installing the "curl" extension to use "%s" instead.';
  195. } else {
  196. $msg = 'try using "%s" instead.';
  197. }
  198. throw new InvalidArgumentException(sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg, __CLASS__, CurlHttpClient::class));
  199. }
  200. $alternatives = [];
  201. foreach ($defaultOptions as $k => $v) {
  202. if (levenshtein($name, $k) <= \strlen($name) / 3 || str_contains($k, $name)) {
  203. $alternatives[] = $k;
  204. }
  205. }
  206. throw new InvalidArgumentException(sprintf('Unsupported option "%s" passed to "%s", did you mean "%s"?', $name, __CLASS__, implode('", "', $alternatives ?: array_keys($defaultOptions))));
  207. }
  208. return $options;
  209. }
  210. /**
  211. * @return string[][]
  212. *
  213. * @throws InvalidArgumentException When an invalid header is found
  214. */
  215. private static function normalizeHeaders(array $headers): array
  216. {
  217. $normalizedHeaders = [];
  218. foreach ($headers as $name => $values) {
  219. if (\is_object($values) && method_exists($values, '__toString')) {
  220. $values = (string) $values;
  221. }
  222. if (\is_int($name)) {
  223. if (!\is_string($values)) {
  224. throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
  225. }
  226. [$name, $values] = explode(':', $values, 2);
  227. $values = [ltrim($values)];
  228. } elseif (!is_iterable($values)) {
  229. if (\is_object($values)) {
  230. throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
  231. }
  232. $values = (array) $values;
  233. }
  234. $lcName = strtolower($name);
  235. $normalizedHeaders[$lcName] = [];
  236. foreach ($values as $value) {
  237. $normalizedHeaders[$lcName][] = $value = $name.': '.$value;
  238. if (\strlen($value) !== strcspn($value, "\r\n\0")) {
  239. throw new InvalidArgumentException(sprintf('Invalid header: CR/LF/NUL found in "%s".', $value));
  240. }
  241. }
  242. }
  243. return $normalizedHeaders;
  244. }
  245. /**
  246. * @param array|string|resource|\Traversable|\Closure $body
  247. *
  248. * @return string|resource|\Closure
  249. *
  250. * @throws InvalidArgumentException When an invalid body is passed
  251. */
  252. private static function normalizeBody($body)
  253. {
  254. if (\is_array($body)) {
  255. array_walk_recursive($body, $caster = static function (&$v) use (&$caster) {
  256. if (\is_object($v)) {
  257. if ($vars = get_object_vars($v)) {
  258. array_walk_recursive($vars, $caster);
  259. $v = $vars;
  260. } elseif (method_exists($v, '__toString')) {
  261. $v = (string) $v;
  262. }
  263. }
  264. });
  265. return http_build_query($body, '', '&');
  266. }
  267. if (\is_string($body)) {
  268. return $body;
  269. }
  270. $generatorToCallable = static function (\Generator $body): \Closure {
  271. return static function () use ($body) {
  272. while ($body->valid()) {
  273. $chunk = $body->current();
  274. $body->next();
  275. if ('' !== $chunk) {
  276. return $chunk;
  277. }
  278. }
  279. return '';
  280. };
  281. };
  282. if ($body instanceof \Generator) {
  283. return $generatorToCallable($body);
  284. }
  285. if ($body instanceof \Traversable) {
  286. return $generatorToCallable((static function ($body) { yield from $body; })($body));
  287. }
  288. if ($body instanceof \Closure) {
  289. $r = new \ReflectionFunction($body);
  290. $body = $r->getClosure();
  291. if ($r->isGenerator()) {
  292. $body = $body(self::$CHUNK_SIZE);
  293. return $generatorToCallable($body);
  294. }
  295. return $body;
  296. }
  297. if (!\is_array(@stream_get_meta_data($body))) {
  298. throw new InvalidArgumentException(sprintf('Option "body" must be string, stream resource, iterable or callable, "%s" given.', get_debug_type($body)));
  299. }
  300. return $body;
  301. }
  302. private static function dechunk(string $body): string
  303. {
  304. $h = fopen('php://temp', 'w+');
  305. stream_filter_append($h, 'dechunk', \STREAM_FILTER_WRITE);
  306. fwrite($h, $body);
  307. $body = stream_get_contents($h, -1, 0);
  308. rewind($h);
  309. ftruncate($h, 0);
  310. if (fwrite($h, '-') && '' !== stream_get_contents($h, -1, 0)) {
  311. throw new TransportException('Request body has broken chunked encoding.');
  312. }
  313. return $body;
  314. }
  315. /**
  316. * @param string|string[] $fingerprint
  317. *
  318. * @throws InvalidArgumentException When an invalid fingerprint is passed
  319. */
  320. private static function normalizePeerFingerprint($fingerprint): array
  321. {
  322. if (\is_string($fingerprint)) {
  323. switch (\strlen($fingerprint = str_replace(':', '', $fingerprint))) {
  324. case 32: $fingerprint = ['md5' => $fingerprint]; break;
  325. case 40: $fingerprint = ['sha1' => $fingerprint]; break;
  326. case 44: $fingerprint = ['pin-sha256' => [$fingerprint]]; break;
  327. case 64: $fingerprint = ['sha256' => $fingerprint]; break;
  328. default: throw new InvalidArgumentException(sprintf('Cannot auto-detect fingerprint algorithm for "%s".', $fingerprint));
  329. }
  330. } elseif (\is_array($fingerprint)) {
  331. foreach ($fingerprint as $algo => $hash) {
  332. $fingerprint[$algo] = 'pin-sha256' === $algo ? (array) $hash : str_replace(':', '', $hash);
  333. }
  334. } else {
  335. throw new InvalidArgumentException(sprintf('Option "peer_fingerprint" must be string or array, "%s" given.', get_debug_type($fingerprint)));
  336. }
  337. return $fingerprint;
  338. }
  339. /**
  340. * @param mixed $value
  341. *
  342. * @throws InvalidArgumentException When the value cannot be json-encoded
  343. */
  344. private static function jsonEncode($value, int $flags = null, int $maxDepth = 512): string
  345. {
  346. $flags = $flags ?? (\JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_PRESERVE_ZERO_FRACTION);
  347. try {
  348. $value = json_encode($value, $flags | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0), $maxDepth);
  349. } catch (\JsonException $e) {
  350. throw new InvalidArgumentException('Invalid value for "json" option: '.$e->getMessage());
  351. }
  352. if (\PHP_VERSION_ID < 70300 && \JSON_ERROR_NONE !== json_last_error() && (false === $value || !($flags & \JSON_PARTIAL_OUTPUT_ON_ERROR))) {
  353. throw new InvalidArgumentException('Invalid value for "json" option: '.json_last_error_msg());
  354. }
  355. return $value;
  356. }
  357. /**
  358. * Resolves a URL against a base URI.
  359. *
  360. * @see https://tools.ietf.org/html/rfc3986#section-5.2.2
  361. *
  362. * @throws InvalidArgumentException When an invalid URL is passed
  363. */
  364. private static function resolveUrl(array $url, ?array $base, array $queryDefaults = []): array
  365. {
  366. if (null !== $base && '' === ($base['scheme'] ?? '').($base['authority'] ?? '')) {
  367. throw new InvalidArgumentException(sprintf('Invalid "base_uri" option: host or scheme is missing in "%s".', implode('', $base)));
  368. }
  369. if (null === $url['scheme'] && (null === $base || null === $base['scheme'])) {
  370. throw new InvalidArgumentException(sprintf('Invalid URL: scheme is missing in "%s". Did you forget to add "http(s)://"?', implode('', $base ?? $url)));
  371. }
  372. if (null === $base && '' === $url['scheme'].$url['authority']) {
  373. throw new InvalidArgumentException(sprintf('Invalid URL: no "base_uri" option was provided and host or scheme is missing in "%s".', implode('', $url)));
  374. }
  375. if (null !== $url['scheme']) {
  376. $url['path'] = self::removeDotSegments($url['path'] ?? '');
  377. } else {
  378. if (null !== $url['authority']) {
  379. $url['path'] = self::removeDotSegments($url['path'] ?? '');
  380. } else {
  381. if (null === $url['path']) {
  382. $url['path'] = $base['path'];
  383. $url['query'] = $url['query'] ?? $base['query'];
  384. } else {
  385. if ('/' !== $url['path'][0]) {
  386. if (null === $base['path']) {
  387. $url['path'] = '/'.$url['path'];
  388. } else {
  389. $segments = explode('/', $base['path']);
  390. array_splice($segments, -1, 1, [$url['path']]);
  391. $url['path'] = implode('/', $segments);
  392. }
  393. }
  394. $url['path'] = self::removeDotSegments($url['path']);
  395. }
  396. $url['authority'] = $base['authority'];
  397. if ($queryDefaults) {
  398. $url['query'] = '?'.self::mergeQueryString(substr($url['query'] ?? '', 1), $queryDefaults, false);
  399. }
  400. }
  401. $url['scheme'] = $base['scheme'];
  402. }
  403. if ('' === ($url['path'] ?? '')) {
  404. $url['path'] = '/';
  405. }
  406. if ('?' === ($url['query'] ?? '')) {
  407. $url['query'] = null;
  408. }
  409. return $url;
  410. }
  411. /**
  412. * Parses a URL and fixes its encoding if needed.
  413. *
  414. * @throws InvalidArgumentException When an invalid URL is passed
  415. */
  416. private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80, 'https' => 443]): array
  417. {
  418. if (false === $parts = parse_url($url)) {
  419. throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url));
  420. }
  421. if ($query) {
  422. $parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true);
  423. }
  424. $port = $parts['port'] ?? 0;
  425. if (null !== $scheme = $parts['scheme'] ?? null) {
  426. if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) {
  427. throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".', $url));
  428. }
  429. $port = $allowedSchemes[$scheme] === $port ? 0 : $port;
  430. $scheme .= ':';
  431. }
  432. if (null !== $host = $parts['host'] ?? null) {
  433. if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/', $host)) {
  434. throw new InvalidArgumentException(sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".', $host));
  435. }
  436. $host = \defined('INTL_IDNA_VARIANT_UTS46') ? idn_to_ascii($host, \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46) ?: strtolower($host) : strtolower($host);
  437. $host .= $port ? ':'.$port : '';
  438. }
  439. foreach (['user', 'pass', 'path', 'query', 'fragment'] as $part) {
  440. if (!isset($parts[$part])) {
  441. continue;
  442. }
  443. if (str_contains($parts[$part], '%')) {
  444. // https://tools.ietf.org/html/rfc3986#section-2.3
  445. $parts[$part] = preg_replace_callback('/%(?:2[DE]|3[0-9]|[46][1-9A-F]|5F|[57][0-9A]|7E)++/i', function ($m) { return rawurldecode($m[0]); }, $parts[$part]);
  446. }
  447. // https://tools.ietf.org/html/rfc3986#section-3.3
  448. $parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()[\]*+,;=:@{}%]++#", function ($m) { return rawurlencode($m[0]); }, $parts[$part]);
  449. }
  450. return [
  451. 'scheme' => $scheme,
  452. 'authority' => null !== $host ? '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' : '').$host : null,
  453. 'path' => isset($parts['path'][0]) ? $parts['path'] : null,
  454. 'query' => isset($parts['query']) ? '?'.$parts['query'] : null,
  455. 'fragment' => isset($parts['fragment']) ? '#'.$parts['fragment'] : null,
  456. ];
  457. }
  458. /**
  459. * Removes dot-segments from a path.
  460. *
  461. * @see https://tools.ietf.org/html/rfc3986#section-5.2.4
  462. */
  463. private static function removeDotSegments(string $path)
  464. {
  465. $result = '';
  466. while (!\in_array($path, ['', '.', '..'], true)) {
  467. if ('.' === $path[0] && (str_starts_with($path, $p = '../') || str_starts_with($path, $p = './'))) {
  468. $path = substr($path, \strlen($p));
  469. } elseif ('/.' === $path || str_starts_with($path, '/./')) {
  470. $path = substr_replace($path, '/', 0, 3);
  471. } elseif ('/..' === $path || str_starts_with($path, '/../')) {
  472. $i = strrpos($result, '/');
  473. $result = $i ? substr($result, 0, $i) : '';
  474. $path = substr_replace($path, '/', 0, 4);
  475. } else {
  476. $i = strpos($path, '/', 1) ?: \strlen($path);
  477. $result .= substr($path, 0, $i);
  478. $path = substr($path, $i);
  479. }
  480. }
  481. return $result;
  482. }
  483. /**
  484. * Merges and encodes a query array with a query string.
  485. *
  486. * @throws InvalidArgumentException When an invalid query-string value is passed
  487. */
  488. private static function mergeQueryString(?string $queryString, array $queryArray, bool $replace): ?string
  489. {
  490. if (!$queryArray) {
  491. return $queryString;
  492. }
  493. $query = [];
  494. if (null !== $queryString) {
  495. foreach (explode('&', $queryString) as $v) {
  496. if ('' !== $v) {
  497. $k = urldecode(explode('=', $v, 2)[0]);
  498. $query[$k] = (isset($query[$k]) ? $query[$k].'&' : '').$v;
  499. }
  500. }
  501. }
  502. if ($replace) {
  503. foreach ($queryArray as $k => $v) {
  504. if (null === $v) {
  505. unset($query[$k]);
  506. }
  507. }
  508. }
  509. $queryString = http_build_query($queryArray, '', '&', \PHP_QUERY_RFC3986);
  510. $queryArray = [];
  511. if ($queryString) {
  512. if (str_contains($queryString, '%')) {
  513. // https://tools.ietf.org/html/rfc3986#section-2.3 + some chars not encoded by browsers
  514. $queryString = strtr($queryString, [
  515. '%21' => '!',
  516. '%24' => '$',
  517. '%28' => '(',
  518. '%29' => ')',
  519. '%2A' => '*',
  520. '%2F' => '/',
  521. '%3A' => ':',
  522. '%3B' => ';',
  523. '%40' => '@',
  524. '%5B' => '[',
  525. '%5D' => ']',
  526. ]);
  527. }
  528. foreach (explode('&', $queryString) as $v) {
  529. $queryArray[rawurldecode(explode('=', $v, 2)[0])] = $v;
  530. }
  531. }
  532. return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
  533. }
  534. /**
  535. * Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
  536. */
  537. private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array
  538. {
  539. if (null === $proxy = self::getProxyUrl($proxy, $url)) {
  540. return null;
  541. }
  542. $proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
  543. if (!isset($proxy['host'])) {
  544. throw new TransportException('Invalid HTTP proxy: host is missing.');
  545. }
  546. if ('http' === $proxy['scheme']) {
  547. $proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
  548. } elseif ('https' === $proxy['scheme']) {
  549. $proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
  550. } else {
  551. throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme']));
  552. }
  553. $noProxy = $noProxy ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
  554. $noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
  555. return [
  556. 'url' => $proxyUrl,
  557. 'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
  558. 'no_proxy' => $noProxy,
  559. ];
  560. }
  561. private static function getProxyUrl(?string $proxy, array $url): ?string
  562. {
  563. if (null !== $proxy) {
  564. return $proxy;
  565. }
  566. // Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
  567. $proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
  568. if ('https:' === $url['scheme']) {
  569. $proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
  570. }
  571. return $proxy;
  572. }
  573. private static function shouldBuffer(array $headers): bool
  574. {
  575. if (null === $contentType = $headers['content-type'][0] ?? null) {
  576. return false;
  577. }
  578. if (false !== $i = strpos($contentType, ';')) {
  579. $contentType = substr($contentType, 0, $i);
  580. }
  581. return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i', $contentType);
  582. }
  583. }