Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
CurlTransport
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
210
0.00% covered (danger)
0.00%
0 / 1
 send
0.00% covered (danger)
0.00%
0 / 76
0.00% covered (danger)
0.00%
0 / 1
210
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ResourceLoader\Transport;
6
7use Phpdftk\ResourceLoader\Exception\FetchFailedException;
8
9/**
10 * curl-based HTTP transport. Single-shot per call — caller chains
11 * redirects + handles cap enforcement.
12 *
13 * Each call constructs a fresh curl handle so requests don't share
14 * state. cookies, sessions, keep-alive — none of these are wanted
15 * for a one-shot resource fetcher.
16 */
17final class CurlTransport implements TransportInterface
18{
19    public function send(
20        string $url,
21        array $headers,
22        int $timeoutSeconds,
23        int $maxBodyBytes,
24    ): RawResponse {
25        if (!function_exists('curl_init')) {
26            throw new FetchFailedException('ext-curl is not loaded; CurlTransport cannot run.');
27        }
28
29        $handle = curl_init();
30        if ($handle === false) {
31            throw new FetchFailedException('curl_init() returned false.');
32        }
33
34        $headerLines = [];
35        foreach ($headers as $name => $value) {
36            $headerLines[] = $name . ': ' . $value;
37        }
38
39        // Response header collector. Curl calls this once per
40        // header line including blank separators between
41        // header blocks (which we ignore).
42        $responseHeaders = [];
43        $headerCallback = static function ($ch, string $line) use (&$responseHeaders): int {
44            unset($ch);
45            $colon = strpos($line, ':');
46            if ($colon !== false) {
47                $name = strtolower(trim(substr($line, 0, $colon)));
48                $value = trim(substr($line, $colon + 1));
49                if ($name !== '') {
50                    $responseHeaders[$name] = $value;
51                }
52            }
53            return strlen($line);
54        };
55
56        // Body collector with size cap. Returning less than the
57        // received chunk size signals curl to abort.
58        $bodyBuffer = '';
59        $bodyOverflowed = false;
60        $writeCallback = static function ($ch, string $chunk) use (&$bodyBuffer, &$bodyOverflowed, $maxBodyBytes): int {
61            unset($ch);
62            $chunkLen = strlen($chunk);
63            $remaining = $maxBodyBytes - strlen($bodyBuffer);
64            if ($remaining <= 0) {
65                $bodyOverflowed = true;
66                return 0;
67            }
68            if ($chunkLen > $remaining) {
69                $bodyBuffer .= substr($chunk, 0, $remaining);
70                $bodyOverflowed = true;
71                return $remaining;
72            }
73            $bodyBuffer .= $chunk;
74            return $chunkLen;
75        };
76
77        curl_setopt_array($handle, [
78            CURLOPT_URL => $url,
79            CURLOPT_CONNECTTIMEOUT => $timeoutSeconds,
80            CURLOPT_TIMEOUT => $timeoutSeconds,
81            // We do redirect handling ourselves so the fetcher can
82            // re-check SSRF + strip Authorization across hops.
83            CURLOPT_FOLLOWLOCATION => false,
84            // Defensive defaults — no auto-decoding (we want raw
85            // bytes for image sniffing), no compression unless the
86            // server explicitly negotiates it via Accept-Encoding.
87            CURLOPT_HTTPHEADER => $headerLines,
88            CURLOPT_HEADERFUNCTION => $headerCallback,
89            CURLOPT_WRITEFUNCTION => $writeCallback,
90            CURLOPT_FAILONERROR => false,
91            CURLOPT_SSL_VERIFYPEER => true,
92            CURLOPT_SSL_VERIFYHOST => 2,
93            CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
94            CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
95        ]);
96
97        $success = curl_exec($handle);
98        if ($success === false && !$bodyOverflowed) {
99            $errno = curl_errno($handle);
100            $error = curl_error($handle);
101            curl_close($handle);
102            throw new FetchFailedException(sprintf(
103                'HTTP fetch failed (curl errno %d): %s — %s',
104                $errno,
105                $error,
106                $url,
107            ));
108        }
109        $statusCode = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
110        curl_close($handle);
111
112        if (!is_int($statusCode) || $statusCode === 0) {
113            throw new FetchFailedException(sprintf('HTTP fetch did not return a status code: %s', $url));
114        }
115
116        if ($bodyOverflowed && strlen($bodyBuffer) >= $maxBodyBytes) {
117            throw new FetchFailedException(sprintf(
118                'Response body exceeds the %d-byte limit: %s',
119                $maxBodyBytes,
120                $url,
121            ));
122        }
123
124        return new RawResponse(
125            statusCode: $statusCode,
126            headers: $responseHeaders,
127            body: $bodyBuffer,
128            finalUrl: $url,
129        );
130    }
131}