1<?php
2
3namespace GuzzleHttp\Handler;
4
5use GuzzleHttp\Exception\ConnectException;
6use GuzzleHttp\Exception\RequestException;
7use GuzzleHttp\Promise as P;
8use GuzzleHttp\Promise\FulfilledPromise;
9use GuzzleHttp\Promise\PromiseInterface;
10use GuzzleHttp\Psr7\LazyOpenStream;
11use GuzzleHttp\TransferStats;
12use GuzzleHttp\Utils;
13use Psr\Http\Message\RequestInterface;
14
15/**
16 * Creates curl resources from a request
17 *
18 * @final
19 */
20class CurlFactory implements CurlFactoryInterface
21{
22    public const CURL_VERSION_STR = 'curl_version';
23
24    /**
25     * @deprecated
26     */
27    public const LOW_CURL_VERSION_NUMBER = '7.21.2';
28
29    /**
30     * @var resource[]|\CurlHandle[]
31     */
32    private $handles = [];
33
34    /**
35     * @var int Total number of idle handles to keep in cache
36     */
37    private $maxHandles;
38
39    /**
40     * @param int $maxHandles Maximum number of idle handles.
41     */
42    public function __construct(int $maxHandles)
43    {
44        $this->maxHandles = $maxHandles;
45    }
46
47    public function create(RequestInterface $request, array $options): EasyHandle
48    {
49        if (isset($options['curl']['body_as_string'])) {
50            $options['_body_as_string'] = $options['curl']['body_as_string'];
51            unset($options['curl']['body_as_string']);
52        }
53
54        $easy = new EasyHandle();
55        $easy->request = $request;
56        $easy->options = $options;
57        $conf = $this->getDefaultConf($easy);
58        $this->applyMethod($easy, $conf);
59        $this->applyHandlerOptions($easy, $conf);
60        $this->applyHeaders($easy, $conf);
61        unset($conf['_headers']);
62
63        // Add handler options from the request configuration options
64        if (isset($options['curl'])) {
65            $conf = \array_replace($conf, $options['curl']);
66        }
67
68        $conf[\CURLOPT_HEADERFUNCTION] = $this->createHeaderFn($easy);
69        $easy->handle = $this->handles ? \array_pop($this->handles) : \curl_init();
70        curl_setopt_array($easy->handle, $conf);
71
72        return $easy;
73    }
74
75    public function release(EasyHandle $easy): void
76    {
77        $resource = $easy->handle;
78        unset($easy->handle);
79
80        if (\count($this->handles) >= $this->maxHandles) {
81            \curl_close($resource);
82        } else {
83            // Remove all callback functions as they can hold onto references
84            // and are not cleaned up by curl_reset. Using curl_setopt_array
85            // does not work for some reason, so removing each one
86            // individually.
87            \curl_setopt($resource, \CURLOPT_HEADERFUNCTION, null);
88            \curl_setopt($resource, \CURLOPT_READFUNCTION, null);
89            \curl_setopt($resource, \CURLOPT_WRITEFUNCTION, null);
90            \curl_setopt($resource, \CURLOPT_PROGRESSFUNCTION, null);
91            \curl_reset($resource);
92            $this->handles[] = $resource;
93        }
94    }
95
96    /**
97     * Completes a cURL transaction, either returning a response promise or a
98     * rejected promise.
99     *
100     * @param callable(RequestInterface, array): PromiseInterface $handler
101     * @param CurlFactoryInterface                                $factory Dictates how the handle is released
102     */
103    public static function finish(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface
104    {
105        if (isset($easy->options['on_stats'])) {
106            self::invokeStats($easy);
107        }
108
109        if (!$easy->response || $easy->errno) {
110            return self::finishError($handler, $easy, $factory);
111        }
112
113        // Return the response if it is present and there is no error.
114        $factory->release($easy);
115
116        // Rewind the body of the response if possible.
117        $body = $easy->response->getBody();
118        if ($body->isSeekable()) {
119            $body->rewind();
120        }
121
122        return new FulfilledPromise($easy->response);
123    }
124
125    private static function invokeStats(EasyHandle $easy): void
126    {
127        $curlStats = \curl_getinfo($easy->handle);
128        $curlStats['appconnect_time'] = \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME);
129        $stats = new TransferStats(
130            $easy->request,
131            $easy->response,
132            $curlStats['total_time'],
133            $easy->errno,
134            $curlStats
135        );
136        ($easy->options['on_stats'])($stats);
137    }
138
139    /**
140     * @param callable(RequestInterface, array): PromiseInterface $handler
141     */
142    private static function finishError(callable $handler, EasyHandle $easy, CurlFactoryInterface $factory): PromiseInterface
143    {
144        // Get error information and release the handle to the factory.
145        $ctx = [
146            'errno' => $easy->errno,
147            'error' => \curl_error($easy->handle),
148            'appconnect_time' => \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME),
149        ] + \curl_getinfo($easy->handle);
150        $ctx[self::CURL_VERSION_STR] = \curl_version()['version'];
151        $factory->release($easy);
152
153        // Retry when nothing is present or when curl failed to rewind.
154        if (empty($easy->options['_err_message']) && (!$easy->errno || $easy->errno == 65)) {
155            return self::retryFailedRewind($handler, $easy, $ctx);
156        }
157
158        return self::createRejection($easy, $ctx);
159    }
160
161    private static function createRejection(EasyHandle $easy, array $ctx): PromiseInterface
162    {
163        static $connectionErrors = [
164            \CURLE_OPERATION_TIMEOUTED => true,
165            \CURLE_COULDNT_RESOLVE_HOST => true,
166            \CURLE_COULDNT_CONNECT => true,
167            \CURLE_SSL_CONNECT_ERROR => true,
168            \CURLE_GOT_NOTHING => true,
169        ];
170
171        if ($easy->createResponseException) {
172            return P\Create::rejectionFor(
173                new RequestException(
174                    'An error was encountered while creating the response',
175                    $easy->request,
176                    $easy->response,
177                    $easy->createResponseException,
178                    $ctx
179                )
180            );
181        }
182
183        // If an exception was encountered during the onHeaders event, then
184        // return a rejected promise that wraps that exception.
185        if ($easy->onHeadersException) {
186            return P\Create::rejectionFor(
187                new RequestException(
188                    'An error was encountered during the on_headers event',
189                    $easy->request,
190                    $easy->response,
191                    $easy->onHeadersException,
192                    $ctx
193                )
194            );
195        }
196
197        $message = \sprintf(
198            'cURL error %s: %s (%s)',
199            $ctx['errno'],
200            $ctx['error'],
201            'see https://curl.haxx.se/libcurl/c/libcurl-errors.html'
202        );
203        $uriString = (string) $easy->request->getUri();
204        if ($uriString !== '' && false === \strpos($ctx['error'], $uriString)) {
205            $message .= \sprintf(' for %s', $uriString);
206        }
207
208        // Create a connection exception if it was a specific error code.
209        $error = isset($connectionErrors[$easy->errno])
210            ? new ConnectException($message, $easy->request, null, $ctx)
211            : new RequestException($message, $easy->request, $easy->response, null, $ctx);
212
213        return P\Create::rejectionFor($error);
214    }
215
216    /**
217     * @return array<int|string, mixed>
218     */
219    private function getDefaultConf(EasyHandle $easy): array
220    {
221        $conf = [
222            '_headers' => $easy->request->getHeaders(),
223            \CURLOPT_CUSTOMREQUEST => $easy->request->getMethod(),
224            \CURLOPT_URL => (string) $easy->request->getUri()->withFragment(''),
225            \CURLOPT_RETURNTRANSFER => false,
226            \CURLOPT_HEADER => false,
227            \CURLOPT_CONNECTTIMEOUT => 300,
228        ];
229
230        if (\defined('CURLOPT_PROTOCOLS')) {
231            $conf[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTP | \CURLPROTO_HTTPS;
232        }
233
234        $version = $easy->request->getProtocolVersion();
235        if ($version == 1.1) {
236            $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
237        } elseif ($version == 2.0) {
238            $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0;
239        } else {
240            $conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0;
241        }
242
243        return $conf;
244    }
245
246    private function applyMethod(EasyHandle $easy, array &$conf): void
247    {
248        $body = $easy->request->getBody();
249        $size = $body->getSize();
250
251        if ($size === null || $size > 0) {
252            $this->applyBody($easy->request, $easy->options, $conf);
253
254            return;
255        }
256
257        $method = $easy->request->getMethod();
258        if ($method === 'PUT' || $method === 'POST') {
259            // See https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2
260            if (!$easy->request->hasHeader('Content-Length')) {
261                $conf[\CURLOPT_HTTPHEADER][] = 'Content-Length: 0';
262            }
263        } elseif ($method === 'HEAD') {
264            $conf[\CURLOPT_NOBODY] = true;
265            unset(
266                $conf[\CURLOPT_WRITEFUNCTION],
267                $conf[\CURLOPT_READFUNCTION],
268                $conf[\CURLOPT_FILE],
269                $conf[\CURLOPT_INFILE]
270            );
271        }
272    }
273
274    private function applyBody(RequestInterface $request, array $options, array &$conf): void
275    {
276        $size = $request->hasHeader('Content-Length')
277            ? (int) $request->getHeaderLine('Content-Length')
278            : null;
279
280        // Send the body as a string if the size is less than 1MB OR if the
281        // [curl][body_as_string] request value is set.
282        if (($size !== null && $size < 1000000) || !empty($options['_body_as_string'])) {
283            $conf[\CURLOPT_POSTFIELDS] = (string) $request->getBody();
284            // Don't duplicate the Content-Length header
285            $this->removeHeader('Content-Length', $conf);
286            $this->removeHeader('Transfer-Encoding', $conf);
287        } else {
288            $conf[\CURLOPT_UPLOAD] = true;
289            if ($size !== null) {
290                $conf[\CURLOPT_INFILESIZE] = $size;
291                $this->removeHeader('Content-Length', $conf);
292            }
293            $body = $request->getBody();
294            if ($body->isSeekable()) {
295                $body->rewind();
296            }
297            $conf[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body) {
298                return $body->read($length);
299            };
300        }
301
302        // If the Expect header is not present, prevent curl from adding it
303        if (!$request->hasHeader('Expect')) {
304            $conf[\CURLOPT_HTTPHEADER][] = 'Expect:';
305        }
306
307        // cURL sometimes adds a content-type by default. Prevent this.
308        if (!$request->hasHeader('Content-Type')) {
309            $conf[\CURLOPT_HTTPHEADER][] = 'Content-Type:';
310        }
311    }
312
313    private function applyHeaders(EasyHandle $easy, array &$conf): void
314    {
315        foreach ($conf['_headers'] as $name => $values) {
316            foreach ($values as $value) {
317                $value = (string) $value;
318                if ($value === '') {
319                    // cURL requires a special format for empty headers.
320                    // See https://github.com/guzzle/guzzle/issues/1882 for more details.
321                    $conf[\CURLOPT_HTTPHEADER][] = "$name;";
322                } else {
323                    $conf[\CURLOPT_HTTPHEADER][] = "$name: $value";
324                }
325            }
326        }
327
328        // Remove the Accept header if one was not set
329        if (!$easy->request->hasHeader('Accept')) {
330            $conf[\CURLOPT_HTTPHEADER][] = 'Accept:';
331        }
332    }
333
334    /**
335     * Remove a header from the options array.
336     *
337     * @param string $name    Case-insensitive header to remove
338     * @param array  $options Array of options to modify
339     */
340    private function removeHeader(string $name, array &$options): void
341    {
342        foreach (\array_keys($options['_headers']) as $key) {
343            if (!\strcasecmp($key, $name)) {
344                unset($options['_headers'][$key]);
345
346                return;
347            }
348        }
349    }
350
351    private function applyHandlerOptions(EasyHandle $easy, array &$conf): void
352    {
353        $options = $easy->options;
354        if (isset($options['verify'])) {
355            if ($options['verify'] === false) {
356                unset($conf[\CURLOPT_CAINFO]);
357                $conf[\CURLOPT_SSL_VERIFYHOST] = 0;
358                $conf[\CURLOPT_SSL_VERIFYPEER] = false;
359            } else {
360                $conf[\CURLOPT_SSL_VERIFYHOST] = 2;
361                $conf[\CURLOPT_SSL_VERIFYPEER] = true;
362                if (\is_string($options['verify'])) {
363                    // Throw an error if the file/folder/link path is not valid or doesn't exist.
364                    if (!\file_exists($options['verify'])) {
365                        throw new \InvalidArgumentException("SSL CA bundle not found: {$options['verify']}");
366                    }
367                    // If it's a directory or a link to a directory use CURLOPT_CAPATH.
368                    // If not, it's probably a file, or a link to a file, so use CURLOPT_CAINFO.
369                    if (
370                        \is_dir($options['verify'])
371                        || (
372                            \is_link($options['verify']) === true
373                            && ($verifyLink = \readlink($options['verify'])) !== false
374                            && \is_dir($verifyLink)
375                        )
376                    ) {
377                        $conf[\CURLOPT_CAPATH] = $options['verify'];
378                    } else {
379                        $conf[\CURLOPT_CAINFO] = $options['verify'];
380                    }
381                }
382            }
383        }
384
385        if (!isset($options['curl'][\CURLOPT_ENCODING]) && !empty($options['decode_content'])) {
386            $accept = $easy->request->getHeaderLine('Accept-Encoding');
387            if ($accept) {
388                $conf[\CURLOPT_ENCODING] = $accept;
389            } else {
390                // The empty string enables all available decoders and implicitly
391                // sets a matching 'Accept-Encoding' header.
392                $conf[\CURLOPT_ENCODING] = '';
393                // But as the user did not specify any acceptable encodings we need
394                // to overwrite this implicit header with an empty one.
395                $conf[\CURLOPT_HTTPHEADER][] = 'Accept-Encoding:';
396            }
397        }
398
399        if (!isset($options['sink'])) {
400            // Use a default temp stream if no sink was set.
401            $options['sink'] = \GuzzleHttp\Psr7\Utils::tryFopen('php://temp', 'w+');
402        }
403        $sink = $options['sink'];
404        if (!\is_string($sink)) {
405            $sink = \GuzzleHttp\Psr7\Utils::streamFor($sink);
406        } elseif (!\is_dir(\dirname($sink))) {
407            // Ensure that the directory exists before failing in curl.
408            throw new \RuntimeException(\sprintf('Directory %s does not exist for sink value of %s', \dirname($sink), $sink));
409        } else {
410            $sink = new LazyOpenStream($sink, 'w+');
411        }
412        $easy->sink = $sink;
413        $conf[\CURLOPT_WRITEFUNCTION] = static function ($ch, $write) use ($sink): int {
414            return $sink->write($write);
415        };
416
417        $timeoutRequiresNoSignal = false;
418        if (isset($options['timeout'])) {
419            $timeoutRequiresNoSignal |= $options['timeout'] < 1;
420            $conf[\CURLOPT_TIMEOUT_MS] = $options['timeout'] * 1000;
421        }
422
423        // CURL default value is CURL_IPRESOLVE_WHATEVER
424        if (isset($options['force_ip_resolve'])) {
425            if ('v4' === $options['force_ip_resolve']) {
426                $conf[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
427            } elseif ('v6' === $options['force_ip_resolve']) {
428                $conf[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V6;
429            }
430        }
431
432        if (isset($options['connect_timeout'])) {
433            $timeoutRequiresNoSignal |= $options['connect_timeout'] < 1;
434            $conf[\CURLOPT_CONNECTTIMEOUT_MS] = $options['connect_timeout'] * 1000;
435        }
436
437        if ($timeoutRequiresNoSignal && \strtoupper(\substr(\PHP_OS, 0, 3)) !== 'WIN') {
438            $conf[\CURLOPT_NOSIGNAL] = true;
439        }
440
441        if (isset($options['proxy'])) {
442            if (!\is_array($options['proxy'])) {
443                $conf[\CURLOPT_PROXY] = $options['proxy'];
444            } else {
445                $scheme = $easy->request->getUri()->getScheme();
446                if (isset($options['proxy'][$scheme])) {
447                    $host = $easy->request->getUri()->getHost();
448                    if (isset($options['proxy']['no']) && Utils::isHostInNoProxy($host, $options['proxy']['no'])) {
449                        unset($conf[\CURLOPT_PROXY]);
450                    } else {
451                        $conf[\CURLOPT_PROXY] = $options['proxy'][$scheme];
452                    }
453                }
454            }
455        }
456
457        if (isset($options['crypto_method'])) {
458            if (\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT === $options['crypto_method']) {
459                if (!defined('CURL_SSLVERSION_TLSv1_0')) {
460                    throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.0 not supported by your version of cURL');
461                }
462                $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_0;
463            } elseif (\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT === $options['crypto_method']) {
464                if (!defined('CURL_SSLVERSION_TLSv1_1')) {
465                    throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.1 not supported by your version of cURL');
466                }
467                $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_1;
468            } elseif (\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT === $options['crypto_method']) {
469                if (!defined('CURL_SSLVERSION_TLSv1_2')) {
470                    throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.2 not supported by your version of cURL');
471                }
472                $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_2;
473            } elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT === $options['crypto_method']) {
474                if (!defined('CURL_SSLVERSION_TLSv1_3')) {
475                    throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.3 not supported by your version of cURL');
476                }
477                $conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_3;
478            } else {
479                throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided');
480            }
481        }
482
483        if (isset($options['cert'])) {
484            $cert = $options['cert'];
485            if (\is_array($cert)) {
486                $conf[\CURLOPT_SSLCERTPASSWD] = $cert[1];
487                $cert = $cert[0];
488            }
489            if (!\file_exists($cert)) {
490                throw new \InvalidArgumentException("SSL certificate not found: {$cert}");
491            }
492            // OpenSSL (versions 0.9.3 and later) also support "P12" for PKCS#12-encoded files.
493            // see https://curl.se/libcurl/c/CURLOPT_SSLCERTTYPE.html
494            $ext = pathinfo($cert, \PATHINFO_EXTENSION);
495            if (preg_match('#^(der|p12)$#i', $ext)) {
496                $conf[\CURLOPT_SSLCERTTYPE] = strtoupper($ext);
497            }
498            $conf[\CURLOPT_SSLCERT] = $cert;
499        }
500
501        if (isset($options['ssl_key'])) {
502            if (\is_array($options['ssl_key'])) {
503                if (\count($options['ssl_key']) === 2) {
504                    [$sslKey, $conf[\CURLOPT_SSLKEYPASSWD]] = $options['ssl_key'];
505                } else {
506                    [$sslKey] = $options['ssl_key'];
507                }
508            }
509
510            $sslKey = $sslKey ?? $options['ssl_key'];
511
512            if (!\file_exists($sslKey)) {
513                throw new \InvalidArgumentException("SSL private key not found: {$sslKey}");
514            }
515            $conf[\CURLOPT_SSLKEY] = $sslKey;
516        }
517
518        if (isset($options['progress'])) {
519            $progress = $options['progress'];
520            if (!\is_callable($progress)) {
521                throw new \InvalidArgumentException('progress client option must be callable');
522            }
523            $conf[\CURLOPT_NOPROGRESS] = false;
524            $conf[\CURLOPT_PROGRESSFUNCTION] = static function ($resource, int $downloadSize, int $downloaded, int $uploadSize, int $uploaded) use ($progress) {
525                $progress($downloadSize, $downloaded, $uploadSize, $uploaded);
526            };
527        }
528
529        if (!empty($options['debug'])) {
530            $conf[\CURLOPT_STDERR] = Utils::debugResource($options['debug']);
531            $conf[\CURLOPT_VERBOSE] = true;
532        }
533    }
534
535    /**
536     * This function ensures that a response was set on a transaction. If one
537     * was not set, then the request is retried if possible. This error
538     * typically means you are sending a payload, curl encountered a
539     * "Connection died, retrying a fresh connect" error, tried to rewind the
540     * stream, and then encountered a "necessary data rewind wasn't possible"
541     * error, causing the request to be sent through curl_multi_info_read()
542     * without an error status.
543     *
544     * @param callable(RequestInterface, array): PromiseInterface $handler
545     */
546    private static function retryFailedRewind(callable $handler, EasyHandle $easy, array $ctx): PromiseInterface
547    {
548        try {
549            // Only rewind if the body has been read from.
550            $body = $easy->request->getBody();
551            if ($body->tell() > 0) {
552                $body->rewind();
553            }
554        } catch (\RuntimeException $e) {
555            $ctx['error'] = 'The connection unexpectedly failed without '
556                .'providing an error. The request would have been retried, '
557                .'but attempting to rewind the request body failed. '
558                .'Exception: '.$e;
559
560            return self::createRejection($easy, $ctx);
561        }
562
563        // Retry no more than 3 times before giving up.
564        if (!isset($easy->options['_curl_retries'])) {
565            $easy->options['_curl_retries'] = 1;
566        } elseif ($easy->options['_curl_retries'] == 2) {
567            $ctx['error'] = 'The cURL request was retried 3 times '
568                .'and did not succeed. The most likely reason for the failure '
569                .'is that cURL was unable to rewind the body of the request '
570                .'and subsequent retries resulted in the same error. Turn on '
571                .'the debug option to see what went wrong. See '
572                .'https://bugs.php.net/bug.php?id=47204 for more information.';
573
574            return self::createRejection($easy, $ctx);
575        } else {
576            ++$easy->options['_curl_retries'];
577        }
578
579        return $handler($easy->request, $easy->options);
580    }
581
582    private function createHeaderFn(EasyHandle $easy): callable
583    {
584        if (isset($easy->options['on_headers'])) {
585            $onHeaders = $easy->options['on_headers'];
586
587            if (!\is_callable($onHeaders)) {
588                throw new \InvalidArgumentException('on_headers must be callable');
589            }
590        } else {
591            $onHeaders = null;
592        }
593
594        return static function ($ch, $h) use (
595            $onHeaders,
596            $easy,
597            &$startingResponse
598        ) {
599            $value = \trim($h);
600            if ($value === '') {
601                $startingResponse = true;
602                try {
603                    $easy->createResponse();
604                } catch (\Exception $e) {
605                    $easy->createResponseException = $e;
606
607                    return -1;
608                }
609                if ($onHeaders !== null) {
610                    try {
611                        $onHeaders($easy->response);
612                    } catch (\Exception $e) {
613                        // Associate the exception with the handle and trigger
614                        // a curl header write error by returning 0.
615                        $easy->onHeadersException = $e;
616
617                        return -1;
618                    }
619                }
620            } elseif ($startingResponse) {
621                $startingResponse = false;
622                $easy->headers = [$value];
623            } else {
624                $easy->headers[] = $value;
625            }
626
627            return \strlen($h);
628        };
629    }
630
631    public function __destruct()
632    {
633        foreach ($this->handles as $id => $handle) {
634            \curl_close($handle);
635            unset($this->handles[$id]);
636        }
637    }
638}
639