Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.05% covered (success)
99.05%
104 / 105
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
RequestFormatter
99.05% covered (success)
99.05%
104 / 105
92.86% covered (success)
92.86%
13 / 14
50
0.00% covered (danger)
0.00%
0 / 1
 format
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 emit
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 resolveBodySource
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 bodyMessage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 announcedLength
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 isEmptyPreview
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 bodyCarrier
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 buildEncapsulatedHeader
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 renderIcapHead
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 renderHttpRequestHeaders
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 renderHttpResponseHeaders
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 renderFields
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isHttpVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 chunkBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap;
22
23use Ndrstmr\Icap\DTO\HttpRequest;
24use Ndrstmr\Icap\DTO\HttpResponse;
25use Ndrstmr\Icap\DTO\IcapRequest;
26
27/**
28 * RFC 3507–conformant formatter for ICAP requests.
29 *
30 * The formatter yields the request in three phases so large encapsulated
31 * HTTP bodies never have to be buffered in memory:
32 *
33 *   1. ICAP request line + ICAP headers + blank line (with computed
34 *      `Encapsulated` offsets).
35 *   2. The encapsulated HTTP header block(s).
36 *   3. The encapsulated HTTP body, transfer-encoded as HTTP/1.1 chunks,
37 *      terminated by either `0\r\n\r\n` or `0; ieof\r\n\r\n` for a
38 *      preview that already carries the complete payload (§4.5).
39 *
40 * Methods, URI, request and status lines and every header name and value
41 * are checked when {@see format()} is called, before the first chunk
42 * exists. Invalid metadata throws {@see \InvalidArgumentException}, so not
43 * a single byte of such a request reaches the socket. Body bytes are
44 * binary and pass unchecked.
45 *
46 * A stream body can only be measured while it is read. When its message
47 * announces a length in a single plain `Content-Length` field, the stream
48 * has to deliver exactly that many bytes: the chunks are produced lazily,
49 * and a stream that is shorter or longer raises a \RuntimeException before
50 * the final chunk is written, instead of ending as a complete body of
51 * another size. That holds for a whole body and for a preview that ends in
52 * `ieof`. A preview that carries only the start of the body is not held to
53 * it: the field still describes the whole body, which follows after
54 * `100 Continue`. A message without such a field is sent as it is.
55 *
56 * @see https://www.rfc-editor.org/rfc/rfc3507#section-4
57 */
58final class RequestFormatter implements RequestFormatterInterface
59{
60    /**
61     * @throws \InvalidArgumentException When the request metadata cannot be
62     *         sent as given
63     */
64    #[\Override]
65    public function format(IcapRequest $request): iterable
66    {
67        // Render and check every header block before handing out the first
68        // chunk: an invalid request fails here, not after its ICAP head has
69        // reached the socket. The block lengths also give the `*-body`
70        // offset, which MUST appear in the Encapsulated header before the
71        // body is written on the wire (RFC 3507 §4.4.1).
72        $reqHeaderBlock = $request->encapsulatedRequest !== null
73            ? $this->renderHttpRequestHeaders($request->encapsulatedRequest)
74            : '';
75        $resHeaderBlock = $request->encapsulatedResponse !== null
76            ? $this->renderHttpResponseHeaders($request->encapsulatedResponse)
77            : '';
78
79        $bodySource = $this->resolveBodySource($request);
80        $bodyLength = is_resource($bodySource) || $bodySource instanceof LocalFile ? $this->announcedLength($request) : null;
81
82        $encapsulated = $this->buildEncapsulatedHeader(
83            hasReqHeaders: $reqHeaderBlock !== '',
84            reqHeaderLength: strlen($reqHeaderBlock),
85            hasResHeaders: $resHeaderBlock !== '',
86            resHeaderLength: strlen($resHeaderBlock),
87            bodyCarrier: $this->bodyCarrier($request),
88            hasBody: $bodySource !== null,
89        );
90
91        $icapHead = $this->renderIcapHead($request, $encapsulated);
92
93        return $this->emit(
94            $icapHead,
95            $reqHeaderBlock,
96            $resHeaderBlock,
97            $bodySource,
98            $bodyLength,
99            $request->previewIsComplete,
100        );
101    }
102
103    /**
104     * Yield the rendered header blocks, then stream the body.
105     *
106     * @param  string|resource|LocalFile|null $bodySource
107     * @param  int|null                       $bodyLength bytes a stream body has to deliver
108     * @return iterable<string>
109     */
110    private function emit(
111        string $icapHead,
112        string $reqHeaderBlock,
113        string $resHeaderBlock,
114        mixed $bodySource,
115        ?int $bodyLength,
116        bool $previewIsComplete,
117    ): iterable {
118        yield $icapHead;
119
120        if ($reqHeaderBlock !== '') {
121            yield $reqHeaderBlock;
122        }
123        if ($resHeaderBlock !== '') {
124            yield $resHeaderBlock;
125        }
126
127        if ($bodySource !== null) {
128            yield from $this->chunkBody($bodySource, $bodyLength, $previewIsComplete);
129        }
130    }
131
132    /**
133     * @return string|resource|LocalFile|null
134     */
135    private function resolveBodySource(IcapRequest $request): mixed
136    {
137        $message = $this->bodyMessage($request);
138        if ($message !== null) {
139            return $message->body;
140        }
141        return $this->isEmptyPreview($request) ? '' : null;
142    }
143
144    /**
145     * The encapsulated message whose body goes out. A response body wins
146     * over a request body when both are present.
147     */
148    private function bodyMessage(IcapRequest $request): HttpRequest|HttpResponse|null
149    {
150        if ($request->encapsulatedResponse?->body !== null && $request->encapsulatedResponse->body !== '') {
151            return $request->encapsulatedResponse;
152        }
153        if ($request->encapsulatedRequest?->body !== null && $request->encapsulatedRequest->body !== '') {
154            return $request->encapsulatedRequest;
155        }
156        return null;
157    }
158
159    /**
160     * The length the message that carries the body announces for it: a single
161     * `Content-Length` value, in any spelling of the field, that is a plain
162     * decimal number. Several differing values or anything else leave the
163     * message without an announced length, and its stream is not held to one.
164     * Neither is the stream of a partial preview: it is the start of the body
165     * that the field describes, not all of it.
166     */
167    private function announcedLength(IcapRequest $request): ?int
168    {
169        if ($request->isPartialPreview()) {
170            return null;
171        }
172
173        $message = $this->bodyMessage($request);
174        if ($message === null) {
175            return null;
176        }
177
178        $values = array_values(array_unique(
179            HeaderFields::values(HeaderFields::validated($message->headers, 'HTTP'), 'Content-Length'),
180        ));
181        if (count($values) !== 1 || preg_match('/^[0-9]{1,18}\z/', $values[0]) !== 1) {
182            return null;
183        }
184
185        return (int) $values[0];
186    }
187
188    /**
189     * `Preview: 0` announces a body whose preview is empty: its chunk stream
190     * ends at once and the server answers `100 Continue` for the rest
191     * (RFC 3507 §4.5). An empty string must not read as "no body" there: the
192     * message would go out as `null-body`, and a server could answer 204 for
193     * a body it was never offered.
194     */
195    private function isEmptyPreview(IcapRequest $request): bool
196    {
197        return !$request->previewIsComplete
198            && HeaderFields::first($request->headers, ['Preview']) === '0'
199            && ($request->encapsulatedResponse?->body === '' || $request->encapsulatedRequest?->body === '');
200    }
201
202    /**
203     * Which encapsulated section the body belongs to, per RFC 3507 §4.4.
204     * A RESPMOD with a response body uses `res-body`; a REQMOD with a
205     * request body uses `req-body`. An encapsulated response body wins
206     * over a request body when both are present (the response is what
207     * gets modified on the way back).
208     */
209    private function bodyCarrier(IcapRequest $request): string
210    {
211        $response = $request->encapsulatedResponse;
212        if ($response?->body !== null && ($response->body !== '' || $this->isEmptyPreview($request))) {
213            return 'res-body';
214        }
215        return 'req-body';
216    }
217
218    private function buildEncapsulatedHeader(
219        bool $hasReqHeaders,
220        int $reqHeaderLength,
221        bool $hasResHeaders,
222        int $resHeaderLength,
223        string $bodyCarrier,
224        bool $hasBody,
225    ): string {
226        $parts = [];
227        $offset = 0;
228
229        if ($hasReqHeaders) {
230            $parts[] = "req-hdr={$offset}";
231            $offset += $reqHeaderLength;
232        }
233        if ($hasResHeaders) {
234            $parts[] = "res-hdr={$offset}";
235            $offset += $resHeaderLength;
236        }
237
238        if ($hasBody) {
239            $parts[] = "{$bodyCarrier}={$offset}";
240        } else {
241            $parts[] = "null-body={$offset}";
242        }
243
244        return implode(', ', $parts);
245    }
246
247    private function renderIcapHead(IcapRequest $request, string $encapsulated): string
248    {
249        if (!HeaderFields::isToken($request->method)) {
250            throw new \InvalidArgumentException('ICAP method must be a token.');
251        }
252        $host = IcapUri::authority($request->uri);
253
254        // Host and Encapsulated describe this very message, so they come
255        // from the URI and the computed byte layout only. A caller value
256        // for either is dropped in any spelling instead of replacing them
257        // or going out as a second field.
258        $headers = HeaderFields::without(
259            HeaderFields::validated($request->headers, 'ICAP'),
260            ['Host', 'Encapsulated'],
261        );
262
263        // Host first for readability, then the remaining headers in the
264        // order the caller supplied them, then Encapsulated last (it's the
265        // header ICAP parsers rely on).
266        return sprintf('%s %s ICAP/1.0', $request->method, $request->uri) . "\r\n"
267            . 'Host: ' . $host . "\r\n"
268            . $this->renderFields($headers)
269            . 'Encapsulated: ' . $encapsulated . "\r\n"
270            . "\r\n";
271    }
272
273    private function renderHttpRequestHeaders(HttpRequest $req): string
274    {
275        // request-line = method SP request-target SP HTTP-version (RFC 7230 §3.1.1)
276        if (!HeaderFields::isToken($req->method)
277            || preg_match('/^[^\x00-\x20\x7F]+\z/', $req->requestTarget) !== 1
278            || !$this->isHttpVersion($req->httpVersion)
279        ) {
280            throw new \InvalidArgumentException('Encapsulated HTTP request line is invalid (RFC 7230 §3.1.1).');
281        }
282
283        return sprintf('%s %s %s', $req->method, $req->requestTarget, $req->httpVersion) . "\r\n"
284            . $this->renderFields(HeaderFields::validated($req->headers, 'HTTP'))
285            . "\r\n";
286    }
287
288    private function renderHttpResponseHeaders(HttpResponse $res): string
289    {
290        // status-line = HTTP-version SP status-code SP reason-phrase (RFC 7230 §3.1.2)
291        if (!$this->isHttpVersion($res->httpVersion)
292            || $res->statusCode < 100
293            || $res->statusCode > 999
294            || !HeaderFields::isText($res->reasonPhrase)
295        ) {
296            throw new \InvalidArgumentException('Encapsulated HTTP status line is invalid (RFC 7230 §3.1.2).');
297        }
298
299        return sprintf('%s %d %s', $res->httpVersion, $res->statusCode, $res->reasonPhrase) . "\r\n"
300            . $this->renderFields(HeaderFields::validated($res->headers, 'HTTP'))
301            . "\r\n";
302    }
303
304    /**
305     * @param array<string, list<string>> $headers
306     */
307    private function renderFields(array $headers): string
308    {
309        $block = '';
310        foreach ($headers as $name => $values) {
311            foreach ($values as $value) {
312                $block .= $name . ': ' . $value . "\r\n";
313            }
314        }
315
316        return $block;
317    }
318
319    /**
320     * HTTP-version = HTTP-name "/" DIGIT "." DIGIT (RFC 7230 §2.6).
321     */
322    private function isHttpVersion(string $version): bool
323    {
324        return preg_match('#^HTTP/[0-9]\.[0-9]\z#', $version) === 1;
325    }
326
327    /**
328     * Emit the body as HTTP/1.1 chunked transfer encoding. Delegates
329     * to {@see ChunkedBodyEncoder} so the strict RFC 3507 §4.5
330     * preview-continue path on the same socket can reuse the exact
331     * same encoding rules.
332     *
333     * @param string|resource|LocalFile $body
334     * @param int|null                  $length bytes a stream body has to deliver
335     * @return iterable<string>
336     */
337    private function chunkBody(mixed $body, ?int $length, bool $previewIsComplete): iterable
338    {
339        return (new ChunkedBodyEncoder())->encode($body, $previewIsComplete, $length);
340    }
341}