PluginProbe
Gmail SMTP / trunk
Gmail SMTP vtrunk
1.2.3.21 1.2.3.20 trunk 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.9 1.2.0 1.2.3.14 1.2.3.15 1.2.3.16 1.2.3.18 1.2.3.5
gmail-smtp / google-api-php-client / vendor / guzzlehttp / guzzle / src / Handler / StreamHandler.php

StreamHandler.php in Gmail SMTP trunk, at google-api-php-client/vendor/guzzlehttp/guzzle/src/Handler/StreamHandler.php

1,015 lines 36.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace GuzzleHttp\Handler;
4
5 use GuzzleHttp\Exception\ConnectException;
6 use GuzzleHttp\Exception\RequestException;
7 use GuzzleHttp\Promise as P;
8 use GuzzleHttp\Promise\FulfilledPromise;
9 use GuzzleHttp\Promise\PromiseInterface;
10 use GuzzleHttp\Psr7;
11 use GuzzleHttp\TransferStats;
12 use GuzzleHttp\TransportSharing;
13 use GuzzleHttp\Utils;
14 use Psr\Http\Message\RequestInterface;
15 use Psr\Http\Message\ResponseInterface;
16 use Psr\Http\Message\StreamInterface;
17 use Psr\Http\Message\UriInterface;
18
19 /**
20 * HTTP handler that uses PHP's HTTP stream wrapper.
21 *
22 * @final
23 */
24 class StreamHandler
25 {
26 private const CONNECTION_ERRORS = [
27 'php_network_getaddresses:',
28 'getaddrinfo',
29 'gethostbyname failed',
30 'Connection refused',
31 'No connection could be made because the target machine actively refused it',
32 "couldn't connect to host", // error on HHVM
33 'connection attempt failed',
34 'connect() failed',
35 'Connection timed out',
36 'Operation timed out',
37 'Network is unreachable',
38 'No route to host',
39 'Host is unreachable',
40 'Host is down',
41 'Cannot connect to HTTPS server through proxy',
42 ];
43
44 /**
45 * @var array
46 */
47 private $lastHeaders = [];
48
49 /**
50 * @var string
51 */
52 private $transportSharingMode;
53
54 /**
55 * Accepts an associative array of options:
56 *
57 * - transport_sharing: Optional transport sharing mode.
58 *
59 * @param array{transport_sharing?: mixed} $options Array of options to use with the handler
60 */
61 public function __construct(array $options = [])
62 {
63 $this->transportSharingMode = CurlShareHandleState::normalizeMode(
64 $options['transport_sharing'] ?? null,
65 'transport_sharing'
66 );
67 }
68
69 /**
70 * Sends an HTTP request.
71 *
72 * @param RequestInterface $request Request to send.
73 * @param array $options Request transfer options.
74 */
75 public function __invoke(RequestInterface $request, array $options): PromiseInterface
76 {
77 // Sleep if there is a delay specified.
78 if (isset($options['delay'])) {
79 \usleep($options['delay'] * 1000);
80 }
81
82 $protocolVersion = $request->getProtocolVersion();
83
84 if ('' === $protocolVersion) {
85 \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Sending a request with an empty protocol version is deprecated; guzzlehttp/guzzle 8.0 will reject empty protocol versions.');
86
87 $protocolVersion = '1.1';
88 $request = Psr7\Utils::modifyRequest($request, ['version' => $protocolVersion]);
89 }
90
91 if ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) {
92 throw new ConnectException(sprintf('HTTP/%s is not supported by the stream handler.', $protocolVersion), $request);
93 }
94
95 $startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
96
97 self::triggerUnsupportedRequestOptionDeprecations($request, $options);
98 $this->assertTransportSharingSupported();
99
100 try {
101 // Does not support the expect header.
102 $request = $request->withoutHeader('Expect');
103
104 // Append a content-length header if body size is zero to match
105 // the behavior of `CurlHandler`
106 if (
107 (
108 0 === \strcasecmp('PUT', $request->getMethod())
109 || 0 === \strcasecmp('POST', $request->getMethod())
110 )
111 && 0 === $request->getBody()->getSize()
112 ) {
113 $request = $request->withHeader('Content-Length', '0');
114 }
115
116 return $this->createResponse(
117 $request,
118 $options,
119 $this->createStream($request, $options),
120 $startTime
121 );
122 } catch (\InvalidArgumentException $e) {
123 throw $e;
124 } catch (\Exception $e) {
125 // Determine if the error was a networking error.
126 if (self::isConnectionError($e->getMessage())) {
127 $e = new ConnectException($e->getMessage(), $request, $e);
128 } else {
129 $e = $e instanceof RequestException ? $e : new RequestException($e->getMessage(), $request, null, $e);
130 }
131 $this->invokeStats($options, $request, $startTime, null, $e);
132
133 return P\Create::rejectionFor($e);
134 }
135 }
136
137 private static function isConnectionError(string $message): bool
138 {
139 foreach (self::CONNECTION_ERRORS as $connectionError) {
140 if (false !== \strpos($message, $connectionError)) {
141 return true;
142 }
143 }
144
145 return false;
146 }
147
148 private function invokeStats(
149 array $options,
150 RequestInterface $request,
151 ?float $startTime,
152 ?ResponseInterface $response = null,
153 ?\Throwable $error = null
154 ): void {
155 if (isset($options['on_stats'])) {
156 $stats = new TransferStats($request, $response, Utils::currentTime() - $startTime, $error, []);
157 ($options['on_stats'])($stats);
158 }
159 }
160
161 /**
162 * @param resource $stream
163 */
164 private function createResponse(RequestInterface $request, array $options, $stream, ?float $startTime): PromiseInterface
165 {
166 $hdrs = $this->lastHeaders;
167 $this->lastHeaders = [];
168
169 try {
170 [$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($hdrs);
171 } catch (\Throwable $e) {
172 return $this->rejectResponseCreation($options, $request, $startTime, $e);
173 }
174
175 [$stream, $headers] = $this->checkDecode($options, $headers, $stream);
176 $stream = Psr7\Utils::streamFor($stream);
177 $sink = $stream;
178
179 if (\strcasecmp('HEAD', $request->getMethod())) {
180 $sink = $this->createSink($stream, $options);
181 }
182
183 try {
184 $response = new Psr7\Response($status, $headers, $sink, $ver, $reason);
185 } catch (\Throwable $e) {
186 return $this->rejectResponseCreation($options, $request, $startTime, $e);
187 }
188
189 if (isset($options['on_headers'])) {
190 try {
191 $options['on_headers']($response);
192 } catch (\Throwable $e) {
193 return P\Create::rejectionFor(
194 new RequestException('An error was encountered during the on_headers event', $request, $response, $e)
195 );
196 }
197 }
198
199 // Do not drain when the request is a HEAD request because they have
200 // no body.
201 if ($sink !== $stream) {
202 $this->drain($stream, $sink, $response->getHeaderLine('Content-Length'));
203 }
204
205 $this->invokeStats($options, $request, $startTime, $response, null);
206
207 return new FulfilledPromise($response);
208 }
209
210 private function rejectResponseCreation(
211 array $options,
212 RequestInterface $request,
213 ?float $startTime,
214 \Throwable $previous
215 ): PromiseInterface {
216 $reason = new RequestException(
217 'An error was encountered while creating the response',
218 $request,
219 null,
220 $previous
221 );
222
223 $this->invokeStats($options, $request, $startTime, null, $reason);
224
225 return P\Create::rejectionFor($reason);
226 }
227
228 private function createSink(StreamInterface $stream, array $options): StreamInterface
229 {
230 if (!empty($options['stream'])) {
231 return $stream;
232 }
233
234 $sink = $options['sink'] ?? Psr7\Utils::tryFopen('php://temp', 'r+');
235
236 return \is_string($sink) ? new Psr7\LazyOpenStream($sink, 'w+') : Psr7\Utils::streamFor($sink);
237 }
238
239 /**
240 * @param resource $stream
241 */
242 private function checkDecode(array $options, array $headers, $stream): array
243 {
244 // Automatically decode responses when instructed.
245 if (isset($options['decode_content']) && $options['decode_content'] !== false) {
246 $normalizedKeys = Utils::normalizeHeaderKeys($headers);
247 if (isset($normalizedKeys['content-encoding'])) {
248 $encoding = $headers[$normalizedKeys['content-encoding']];
249 if ($encoding[0] === 'gzip' || $encoding[0] === 'deflate') {
250 $stream = new Psr7\InflateStream(Psr7\Utils::streamFor($stream));
251 $headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']];
252
253 // Remove content-encoding header
254 unset($headers[$normalizedKeys['content-encoding']]);
255
256 // The decoded length cannot be known without inflating the
257 // stream, so keep the original length for inspection and
258 // drop the now-unknown Content-Length header.
259 if (isset($normalizedKeys['content-length'])) {
260 $headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']];
261 unset($headers[$normalizedKeys['content-length']]);
262 }
263 }
264 }
265 }
266
267 return [$stream, $headers];
268 }
269
270 /**
271 * Drains the source stream into the "sink" client option.
272 *
273 * @param string $contentLength Header specifying the amount of
274 * data to read.
275 *
276 * @throws \RuntimeException when the sink option is invalid.
277 */
278 private function drain(StreamInterface $source, StreamInterface $sink, string $contentLength): StreamInterface
279 {
280 // If a content-length header is provided, then stop reading once
281 // that number of bytes has been read. This can prevent infinitely
282 // reading from a stream when dealing with servers that do not honor
283 // Connection: Close headers.
284 Psr7\Utils::copyToStream(
285 $source,
286 $sink,
287 (\strlen($contentLength) > 0 && (int) $contentLength > 0) ? (int) $contentLength : -1
288 );
289
290 $sink->seek(0);
291 $source->close();
292
293 return $sink;
294 }
295
296 /**
297 * Create a resource and check to ensure it was created successfully
298 *
299 * @param callable $callback Callable that returns stream resource
300 *
301 * @return resource
302 *
303 * @throws \RuntimeException on error
304 */
305 private function createResource(callable $callback)
306 {
307 $errors = [];
308 \set_error_handler(static function ($_, $msg, $file, $line) use (&$errors): bool {
309 $errors[] = [
310 'message' => $msg,
311 'file' => $file,
312 'line' => $line,
313 ];
314
315 return true;
316 });
317
318 try {
319 $resource = $callback();
320 } finally {
321 \restore_error_handler();
322 }
323
324 if (!$resource) {
325 $message = 'Error creating resource: ';
326 foreach ($errors as $err) {
327 foreach ($err as $key => $value) {
328 $message .= "[$key] $value".\PHP_EOL;
329 }
330 }
331 throw new \RuntimeException(\trim($message));
332 }
333
334 return $resource;
335 }
336
337 /**
338 * @return resource
339 */
340 private function createStream(RequestInterface $request, array $options)
341 {
342 static $methods;
343 if (!$methods) {
344 $methods = \array_flip(\get_class_methods(__CLASS__));
345 }
346
347 $scheme = $request->getUri()->getScheme();
348 if (!\in_array($scheme, ['http', 'https'], true)) {
349 throw new RequestException(\sprintf("The scheme '%s' is not supported.", $scheme), $request);
350 }
351
352 $protocols = Utils::normalizeProtocols($options['protocols'] ?? ['http', 'https']);
353 if (!\in_array($scheme, $protocols, true)) {
354 throw new RequestException(\sprintf('The scheme "%s" is not allowed by the protocols request option.', $scheme), $request);
355 }
356
357 // HTTP/1.1 streams using the PHP stream wrapper require a
358 // Connection: close header
359 if ($request->getProtocolVersion() === '1.1'
360 && !$request->hasHeader('Connection')
361 ) {
362 $request = $request->withHeader('Connection', 'close');
363 }
364
365 // Ensure SSL is verified by default
366 if (!isset($options['verify'])) {
367 $options['verify'] = true;
368 }
369
370 $params = [];
371 $context = $this->getDefaultContext($request);
372
373 if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) {
374 throw new \InvalidArgumentException('on_headers must be callable');
375 }
376
377 if (!empty($options)) {
378 foreach ($options as $key => $value) {
379 $method = "add_{$key}";
380 if (isset($methods[$method])) {
381 $this->{$method}($request, $context, $value, $params);
382 }
383 }
384 }
385
386 if (isset($options['stream_context'])) {
387 if (!\is_array($options['stream_context'])) {
388 throw new \InvalidArgumentException('stream_context must be an array');
389 }
390 self::triggerConflictingStreamContextOptionDeprecations($options['stream_context']);
391 self::triggerUnsupportedStreamContextOptionDeprecations($options['stream_context']);
392 $context = \array_replace_recursive($context, $options['stream_context']);
393 }
394
395 // Microsoft NTLM authentication only supported with curl handler
396 if (isset($options['auth'][2]) && 'ntlm' === $options['auth'][2]) {
397 throw new \InvalidArgumentException('Microsoft NTLM authentication only supported with curl handler');
398 }
399
400 $uri = $this->resolveHost($request, $options);
401
402 $contextResource = $this->createResource(
403 static function () use ($context, $params) {
404 return \stream_context_create($context, $params);
405 }
406 );
407
408 return $this->createResource(
409 function () use ($uri, $contextResource, $context, $options, $request) {
410 $resource = @\fopen((string) $uri, 'r', false, $contextResource);
411
412 // See https://wiki.php.net/rfc/deprecations_php_8_5#deprecate_the_http_response_header_predefined_variable
413 if (function_exists('http_get_last_response_headers')) {
414 $http_response_header = \http_get_last_response_headers();
415 }
416
417 $this->lastHeaders = $http_response_header ?? [];
418
419 if (false === $resource) {
420 throw new ConnectException(sprintf('Connection refused for URI %s', $uri), $request, null, $context);
421 }
422
423 if (isset($options['read_timeout'])) {
424 $readTimeout = $options['read_timeout'];
425 $sec = (int) $readTimeout;
426 $usec = ($readTimeout - $sec) * 100000;
427 \stream_set_timeout($resource, $sec, $usec);
428 }
429
430 return $resource;
431 }
432 );
433 }
434
435 private function resolveHost(RequestInterface $request, array $options): UriInterface
436 {
437 $uri = $request->getUri();
438
439 $host = $uri->getHost();
440 $hostForIpCheck = $host !== '' && $host[0] === '[' && \substr($host, -1) === ']'
441 ? \substr($host, 1, -1)
442 : $host;
443 if (isset($options['force_ip_resolve']) && !\filter_var($hostForIpCheck, \FILTER_VALIDATE_IP)) {
444 if ('v4' === $options['force_ip_resolve']) {
445 $records = \dns_get_record($uri->getHost(), \DNS_A);
446 if (false === $records || !isset($records[0]['ip'])) {
447 throw new ConnectException(\sprintf("Could not resolve IPv4 address for host '%s'", $uri->getHost()), $request);
448 }
449
450 return $uri->withHost($records[0]['ip']);
451 }
452 if ('v6' === $options['force_ip_resolve']) {
453 $records = \dns_get_record($uri->getHost(), \DNS_AAAA);
454 if (false === $records || !isset($records[0]['ipv6'])) {
455 throw new ConnectException(\sprintf("Could not resolve IPv6 address for host '%s'", $uri->getHost()), $request);
456 }
457
458 return $uri->withHost('['.$records[0]['ipv6'].']');
459 }
460 }
461
462 return $uri;
463 }
464
465 private function getDefaultContext(RequestInterface $request): array
466 {
467 $headers = '';
468 foreach ($request->getHeaders() as $name => $value) {
469 foreach ($value as $val) {
470 $headers .= "$name: $val\r\n";
471 }
472 }
473
474 $context = [
475 'http' => [
476 'method' => $request->getMethod(),
477 'header' => $headers,
478 'protocol_version' => $request->getProtocolVersion(),
479 'ignore_errors' => true,
480 'follow_location' => 0,
481 ],
482 'ssl' => [
483 'peer_name' => $request->getUri()->getHost(),
484 ],
485 ];
486
487 $body = (string) $request->getBody();
488
489 if ('' !== $body) {
490 $context['http']['content'] = $body;
491 // Prevent the HTTP handler from adding a Content-Type header.
492 if (!$request->hasHeader('Content-Type')) {
493 $context['http']['header'] .= "Content-Type:\r\n";
494 }
495 }
496
497 $context['http']['header'] = \rtrim($context['http']['header']);
498
499 return $context;
500 }
501
502 private static function triggerUnsupportedRequestOptionDeprecations(RequestInterface $request, array $options): void
503 {
504 if (
505 \array_key_exists('curl', $options)
506 && $options['curl'] !== null
507 && $options['curl'] !== []
508 && !self::isCurlOptionGeneratedByAuth($options)
509 ) {
510 \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "curl" request option to the stream handler is deprecated; guzzlehttp/guzzle 8.0 will reject this option because the stream handler ignores cURL options.');
511 }
512
513 if (\array_key_exists('expect', $options) && $options['expect'] !== false && $request->hasHeader('Expect')) {
514 \trigger_deprecation('guzzlehttp/guzzle', '7.11', 'Passing the "expect" request option to the stream handler is deprecated when it adds an Expect header; guzzlehttp/guzzle 8.0 will reject this option because the stream handler does not support Expect: 100-Continue.');
515 }
516 }
517
518 private static function triggerConflictingStreamContextOptionDeprecations(array $streamContext): void
519 {
520 $conflictingOptions = self::conflictingStreamContextOptions();
521
522 foreach ($streamContext as $wrapper => $contextOptions) {
523 if (!\is_string($wrapper) || !isset($conflictingOptions[$wrapper]) || !\is_array($contextOptions)) {
524 continue;
525 }
526
527 foreach ($contextOptions as $option => $_) {
528 if (!\is_string($option) || !\array_key_exists($option, $conflictingOptions[$wrapper])) {
529 continue;
530 }
531
532 \trigger_deprecation(
533 'guzzlehttp/guzzle',
534 '7.12',
535 \sprintf(
536 'Passing stream_context.%s.%s in the "stream_context" request option is deprecated; guzzlehttp/guzzle 8.0 will reject this option because it conflicts with Guzzle-managed request handling. Use %s instead.',
537 $wrapper,
538 $option,
539 $conflictingOptions[$wrapper][$option]
540 )
541 );
542 }
543 }
544 }
545
546 private static function triggerUnsupportedStreamContextOptionDeprecations(array $streamContext): void
547 {
548 $unsupportedOptions = self::unsupportedStreamContextOptions($streamContext);
549 if ($unsupportedOptions === []) {
550 return;
551 }
552
553 \trigger_deprecation(
554 'guzzlehttp/guzzle',
555 '7.12',
556 \sprintf(
557 'Passing PHP stream context options outside the built-in stream handler allow-list to the "stream_context" request option is deprecated; guzzlehttp/guzzle 8.0 will reject stream context options outside the allow-list. Deprecated option%s: %s.',
558 \count($unsupportedOptions) === 1 ? '' : 's',
559 \implode(', ', $unsupportedOptions)
560 )
561 );
562 }
563
564 /**
565 * @return string[]
566 */
567 private static function unsupportedStreamContextOptions(array $streamContext): array
568 {
569 $supportedOptions = self::supportedStreamContextOptions();
570 $conflictingOptions = self::conflictingStreamContextOptions();
571 $unsupportedOptions = [];
572
573 foreach ($streamContext as $wrapper => $contextOptions) {
574 if (!\is_string($wrapper) || !isset($supportedOptions[$wrapper])) {
575 if (\is_array($contextOptions)) {
576 foreach ($contextOptions as $option => $_) {
577 if (\is_string($wrapper) && \is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) {
578 continue;
579 }
580
581 $unsupportedOptions[] = \sprintf('stream_context.%s.%s', (string) $wrapper, (string) $option);
582 }
583 } else {
584 $unsupportedOptions[] = \sprintf('stream_context.%s', (string) $wrapper);
585 }
586
587 continue;
588 }
589
590 if (!\is_array($contextOptions)) {
591 $unsupportedOptions[] = \sprintf('stream_context.%s', $wrapper);
592
593 continue;
594 }
595
596 foreach ($contextOptions as $option => $_) {
597 if (\is_string($option) && isset($conflictingOptions[$wrapper]) && \array_key_exists($option, $conflictingOptions[$wrapper])) {
598 continue;
599 }
600
601 if (!\is_string($option) || !\array_key_exists($option, $supportedOptions[$wrapper])) {
602 $unsupportedOptions[] = \sprintf('stream_context.%s.%s', $wrapper, (string) $option);
603 }
604 }
605 }
606
607 return $unsupportedOptions;
608 }
609
610 /**
611 * @return array<string, array<string, true>>
612 */
613 private static function supportedStreamContextOptions(): array
614 {
615 return [
616 'http' => [
617 'request_fulluri' => true,
618 ],
619 'socket' => [
620 'bindto' => true,
621 'tcp_nodelay' => true,
622 ],
623 'ssl' => [
624 'SNI_enabled' => true,
625 'capture_peer_cert' => true,
626 'capture_peer_cert_chain' => true,
627 'ciphers' => true,
628 'disable_compression' => true,
629 'no_ticket' => true,
630 'peer_fingerprint' => true,
631 'security_level' => true,
632 'verify_depth' => true,
633 ],
634 ];
635 }
636
637 /**
638 * @return array<string, array<string, string>>
639 */
640 private static function conflictingStreamContextOptions(): array
641 {
642 return [
643 'http' => [
644 'content' => 'the request body',
645 'follow_location' => 'the "allow_redirects" request option',
646 'header' => 'the request headers',
647 'max_redirects' => 'the "allow_redirects" request option',
648 'method' => 'the request method',
649 'protocol_version' => 'the request protocol version',
650 'proxy' => 'the "proxy" request option',
651 'timeout' => 'the "timeout" request option',
652 ],
653 'ssl' => [
654 'allow_self_signed' => 'the "verify" request option',
655 'cafile' => 'the "verify" request option',
656 'capath' => 'the "verify" request option',
657 'crypto_method' => 'the "crypto_method" request option',
658 'local_cert' => 'the "cert" request option',
659 'local_pk' => 'the "ssl_key" request option',
660 'min_proto_version' => 'the "crypto_method" request option',
661 'passphrase' => 'the "cert" or "ssl_key" request option',
662 'peer_name' => 'the request URI',
663 'verify_peer' => 'the "verify" request option',
664 'verify_peer_name' => 'the "verify" request option',
665 ],
666 ];
667 }
668
669 private function assertTransportSharingSupported(): void
670 {
671 if ($this->transportSharingMode === TransportSharing::HANDLER_REQUIRE) {
672 throw new \InvalidArgumentException('The "transport_sharing" option requires transport sharing, but the stream handler does not support it.');
673 }
674 }
675
676 private static function isCurlOptionGeneratedByAuth(array $options): bool
677 {
678 if (!isset($options['curl']) || !\is_array($options['curl']) || !isset($options['auth'][2]) || !\is_string($options['auth'][2])) {
679 return false;
680 }
681
682 if (!\defined('CURLOPT_HTTPAUTH') || !\defined('CURLOPT_USERPWD')) {
683 return false;
684 }
685
686 $type = \strtolower($options['auth'][2]);
687 if ($type === 'digest') {
688 $httpAuth = \defined('CURLAUTH_DIGEST') ? \constant('CURLAUTH_DIGEST') : null;
689 } elseif ($type === 'ntlm') {
690 $httpAuth = \defined('CURLAUTH_NTLM') ? \constant('CURLAUTH_NTLM') : null;
691 } else {
692 return false;
693 }
694
695 return $httpAuth !== null
696 && \count($options['curl']) === 2
697 && isset($options['curl'][\CURLOPT_HTTPAUTH], $options['curl'][\CURLOPT_USERPWD])
698 && $options['curl'][\CURLOPT_HTTPAUTH] === $httpAuth;
699 }
700
701 /**
702 * @param mixed $value as passed via Request transfer options.
703 *
704 * @return array{0: string, 1: string|null}
705 */
706 private static function normalizeTlsFileOption(string $option, $value): array
707 {
708 $passphrase = null;
709
710 if (\is_array($value)) {
711 if (!isset($value[0]) || !\is_string($value[0])) {
712 throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option));
713 }
714 if (isset($value[1])) {
715 if (!\is_string($value[1])) {
716 throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option));
717 }
718 $passphrase = $value[1];
719 }
720 $value = $value[0];
721 }
722
723 if (!\is_string($value)) {
724 throw new \InvalidArgumentException(\sprintf('Invalid %s request option', $option));
725 }
726
727 return [$value, $passphrase];
728 }
729
730 private static function setTlsPassphrase(array &$options, ?string $passphrase, string $option): void
731 {
732 if ($passphrase === null) {
733 return;
734 }
735
736 if (isset($options['ssl']['passphrase']) && $options['ssl']['passphrase'] !== $passphrase) {
737 throw new \InvalidArgumentException(\sprintf('Cannot use different passphrases for cert and ssl_key with the stream handler; %s conflicts with an existing TLS passphrase.', $option));
738 }
739
740 $options['ssl']['passphrase'] = $passphrase;
741 }
742
743 /**
744 * @param mixed $value as passed via Request transfer options.
745 */
746 private static function assertStreamTlsType(string $option, $value): void
747 {
748 if (!\is_string($value) || $value === '') {
749 throw new \InvalidArgumentException(\sprintf('%s must be a non-empty string', $option));
750 }
751
752 if (\strtoupper($value) !== 'PEM') {
753 throw new \InvalidArgumentException(\sprintf('The stream handler only supports "PEM" for the %s request option.', $option));
754 }
755 }
756
757 /**
758 * @param mixed $value as passed via Request transfer options.
759 */
760 private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void
761 {
762 $uri = null;
763
764 if (!\is_array($value)) {
765 $uri = $value;
766 } else {
767 $scheme = $request->getUri()->getScheme();
768 if (isset($value[$scheme])) {
769 if (
770 !isset($value['no'])
771 || !Utils::isUriInNoProxy($request->getUri(), $value['no'])
772 ) {
773 $uri = $value[$scheme];
774 }
775 }
776 }
777
778 if (!$uri) {
779 return;
780 }
781
782 $parsed = $this->parse_proxy($uri);
783 $options['http']['proxy'] = $parsed['proxy'];
784
785 if ($parsed['auth']) {
786 if (!isset($options['http']['header'])) {
787 $options['http']['header'] = '';
788 }
789 $options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}";
790 }
791 }
792
793 /**
794 * Parses the given proxy URL to make it compatible with the format PHP's stream context expects.
795 */
796 private function parse_proxy(string $url): array
797 {
798 $parsed = \parse_url($url);
799
800 // parse_url() misreads scheme-less proxy authorities like
801 // "user:pass@host"; re-parse only those forms as HTTP.
802 $schemeLessAuthority = \strpos($url, '://') === false && \strncmp($url, '//', 2) !== 0;
803 if ($schemeLessAuthority) {
804 if (\is_array($parsed) && !isset($parsed['scheme']) && isset($parsed['host'], $parsed['port'])) {
805 $parsed['scheme'] = 'http';
806 } elseif (
807 (!\is_array($parsed) || !isset($parsed['host']))
808 && (\strpos($url, '@') !== false || \strncmp($url, '[', 1) === 0)
809 ) {
810 $parsed = \parse_url('http://'.$url);
811 }
812 }
813
814 if (\is_array($parsed) && isset($parsed['scheme']) && \strcasecmp($parsed['scheme'], 'http') === 0) {
815 if (isset($parsed['host'], $parsed['port'])) {
816 $user = $parsed['user'] ?? '';
817 $pass = $parsed['pass'] ?? '';
818 $auth = ($user !== '' || $pass !== '') ? 'Basic '.\base64_encode("{$user}:{$pass}") : null;
819
820 return [
821 'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}",
822 'auth' => $auth,
823 ];
824 }
825 }
826
827 // Return proxy as-is.
828 return [
829 'proxy' => $url,
830 'auth' => null,
831 ];
832 }
833
834 /**
835 * @param mixed $value as passed via Request transfer options.
836 */
837 private function add_timeout(RequestInterface $request, array &$options, $value, array &$params): void
838 {
839 if ($value > 0) {
840 $options['http']['timeout'] = $value;
841 }
842 }
843
844 /**
845 * @param mixed $value as passed via Request transfer options.
846 */
847 private function add_crypto_method(RequestInterface $request, array &$options, $value, array &$params): void
848 {
849 if (
850 $value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT
851 || $value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
852 || $value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
853 || (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && $value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT)
854 ) {
855 $options['http']['crypto_method'] = $value;
856
857 return;
858 }
859
860 throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided');
861 }
862
863 /**
864 * @param mixed $value as passed via Request transfer options.
865 */
866 private function add_verify(RequestInterface $request, array &$options, $value, array &$params): void
867 {
868 if ($value === false) {
869 $options['ssl']['verify_peer'] = false;
870 $options['ssl']['verify_peer_name'] = false;
871
872 return;
873 }
874
875 if (\is_string($value)) {
876 $options['ssl']['cafile'] = $value;
877 if (!\file_exists($value)) {
878 throw new \RuntimeException("SSL CA bundle not found: $value");
879 }
880 } elseif ($value !== true) {
881 throw new \InvalidArgumentException('Invalid verify request option');
882 }
883
884 $options['ssl']['verify_peer'] = true;
885 $options['ssl']['verify_peer_name'] = true;
886 $options['ssl']['allow_self_signed'] = false;
887 }
888
889 /**
890 * @param mixed $value as passed via Request transfer options.
891 */
892 private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void
893 {
894 [$value, $passphrase] = self::normalizeTlsFileOption('cert', $value);
895
896 if (!\file_exists($value)) {
897 throw new \RuntimeException("SSL certificate not found: {$value}");
898 }
899
900 self::setTlsPassphrase($options, $passphrase, 'cert');
901 $options['ssl']['local_cert'] = $value;
902 }
903
904 /**
905 * @param mixed $value as passed via Request transfer options.
906 */
907 private function add_cert_type(RequestInterface $request, array &$options, $value, array &$params): void
908 {
909 self::assertStreamTlsType('cert_type', $value);
910 }
911
912 /**
913 * @param mixed $value as passed via Request transfer options.
914 */
915 private function add_ssl_key(RequestInterface $request, array &$options, $value, array &$params): void
916 {
917 [$value, $passphrase] = self::normalizeTlsFileOption('ssl_key', $value);
918
919 if (!\file_exists($value)) {
920 throw new \RuntimeException("SSL private key not found: {$value}");
921 }
922
923 self::setTlsPassphrase($options, $passphrase, 'ssl_key');
924 $options['ssl']['local_pk'] = $value;
925 }
926
927 /**
928 * @param mixed $value as passed via Request transfer options.
929 */
930 private function add_ssl_key_type(RequestInterface $request, array &$options, $value, array &$params): void
931 {
932 self::assertStreamTlsType('ssl_key_type', $value);
933 }
934
935 /**
936 * @param mixed $value as passed via Request transfer options.
937 */
938 private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void
939 {
940 if (!\is_callable($value)) {
941 throw new \InvalidArgumentException('progress client option must be callable');
942 }
943
944 self::addNotification(
945 $params,
946 static function ($code, $a, $b, $c, $transferred, $total) use ($value) {
947 if ($code == \STREAM_NOTIFY_PROGRESS) {
948 // The upload progress cannot be determined. Use 0 for cURL compatibility:
949 // https://curl.se/libcurl/c/CURLOPT_PROGRESSFUNCTION.html
950 $value($total, $transferred, 0, 0);
951 }
952 }
953 );
954 }
955
956 /**
957 * @param mixed $value as passed via Request transfer options.
958 */
959 private function add_debug(RequestInterface $request, array &$options, $value, array &$params): void
960 {
961 if ($value === false) {
962 return;
963 }
964
965 static $map = [
966 \STREAM_NOTIFY_CONNECT => 'CONNECT',
967 \STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED',
968 \STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT',
969 \STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS',
970 \STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS',
971 \STREAM_NOTIFY_REDIRECTED => 'REDIRECTED',
972 \STREAM_NOTIFY_PROGRESS => 'PROGRESS',
973 \STREAM_NOTIFY_FAILURE => 'FAILURE',
974 \STREAM_NOTIFY_COMPLETED => 'COMPLETED',
975 \STREAM_NOTIFY_RESOLVE => 'RESOLVE',
976 ];
977 static $args = ['severity', 'message', 'message_code', 'bytes_transferred', 'bytes_max'];
978
979 $value = Utils::debugResource($value);
980 $ident = $request->getMethod().' '.$request->getUri()->withFragment('');
981 self::addNotification(
982 $params,
983 static function (int $code, ...$passed) use ($ident, $value, $map, $args): void {
984 \fprintf($value, '<%s> [%s] ', $ident, $map[$code]);
985 foreach (\array_filter($passed) as $i => $v) {
986 \fwrite($value, $args[$i].': "'.$v.'" ');
987 }
988 \fwrite($value, "\n");
989 }
990 );
991 }
992
993 private static function addNotification(array &$params, callable $notify): void
994 {
995 // Wrap the existing function if needed.
996 if (!isset($params['notification'])) {
997 $params['notification'] = $notify;
998 } else {
999 $params['notification'] = self::callArray([
1000 $params['notification'],
1001 $notify,
1002 ]);
1003 }
1004 }
1005
1006 private static function callArray(array $functions): callable
1007 {
1008 return static function (...$args) use ($functions) {
1009 foreach ($functions as $fn) {
1010 $fn(...$args);
1011 }
1012 };
1013 }
1014 }
1015