Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.67% covered (success)
96.67%
29 / 30
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResponseFrameReader
96.67% covered (success)
96.67%
29 / 30
75.00% covered (warning)
75.00%
3 / 4
10
0.00% covered (danger)
0.00%
0 / 1
 __construct
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
6.00
 readFrom
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 readDiscardingBody
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 readInto
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
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\Transport;
22
23use Closure;
24use Ndrstmr\Icap\DTO\IcapResponse;
25use Ndrstmr\Icap\Exception\IcapMalformedResponseException;
26use Ndrstmr\Icap\ResponseDecoder;
27
28/**
29 * Frames ICAP responses off a stream of byte chunks.
30 *
31 * RFC 3507 §5.5 lets servers keep the TCP connection open for the
32 * next request, which means transports cannot rely on socket-EOF to
33 * delimit the response. A response ends where its own layout says (the
34 * `Encapsulated` header, the header sections it gives lengths for, the
35 * chunked body with its last chunk and trailer), and nowhere else: bytes in
36 * a chunk are data, whatever they look like. {@see ResponseDecoder} holds the
37 * rules, and the parser reads a complete response with the same ones, so both
38 * accept or refuse the same bytes.
39 *
40 * The reader stops at the end of a message and asks the producer for nothing
41 * more. Bytes it has already taken past that end belong to no request: a
42 * connection carries one outstanding request at a time (RFC 3507 §4.1). Both
43 * reads refuse them, whether the response is kept or not; bytes that arrive
44 * later are not waited for.
45 */
46final class ResponseFrameReader
47{
48    public function __construct(
49        private readonly int $maxResponseSize,
50        private readonly int $maxHeaderLineLength,
51        private readonly int $maxHeaderCount = 100,
52    ) {
53        if ($maxResponseSize < 1 || $maxHeaderLineLength < 1 || $maxHeaderCount < 1) {
54            throw new \InvalidArgumentException('Reader limits must be >= 1.');
55        }
56    }
57
58    /**
59     * Read a complete ICAP response from the supplied producer.
60     *
61     * The producer is a callable that returns the next chunk of bytes
62     * (or null on EOF). It is invoked only when the reader needs more
63     * bytes to complete the framing.
64     *
65     * @param Closure(): ?string $produce
66     *
67     * @throws IcapMalformedResponseException When the bytes cannot be a legal response, the stream
68     *         ends before the response does, or bytes came in behind it
69     */
70    public function readFrom(Closure $produce): string
71    {
72        $decoder = new ResponseDecoder(
73            maxResponseSize: $this->maxResponseSize,
74            maxHeaderCount: $this->maxHeaderCount,
75            maxHeaderLineLength: $this->maxHeaderLineLength,
76            collectBody: false,
77        );
78        $this->readInto($decoder, $produce);
79
80        return $decoder->frame();
81    }
82
83    /**
84     * Read a complete ICAP response like {@see readFrom()} and check it to its last byte, but keep
85     * neither the frame nor the body: the bytes are used up as they come, so what the reader holds
86     * does not depend on the size of the body. The body is counted, not kept
87     * ({@see IcapResponse::$discardedBodyBytes}). `$maxResponseBytes` is the budget for the bytes of
88     * the whole message, in place of the maximum size of the reader; the limits for header lines and
89     * their number are the reader's.
90     *
91     * @param Closure(): ?string $produce
92     *
93     * @throws IcapMalformedResponseException When the bytes cannot be a legal response, the stream
94     *         ends before the response does, the response has more bytes than the budget, or bytes
95     *         came in behind it
96     */
97    public function readDiscardingBody(Closure $produce, int $maxResponseBytes): IcapResponse
98    {
99        $decoder = new ResponseDecoder(
100            maxResponseSize: $maxResponseBytes,
101            maxHeaderCount: $this->maxHeaderCount,
102            maxHeaderLineLength: $this->maxHeaderLineLength,
103            collectBody: false,
104            discard: true,
105        );
106        $this->readInto($decoder, $produce);
107
108        return $decoder->response();
109    }
110
111    /**
112     * Feeds the decoder until the response is complete, and refuses what came in behind it.
113     *
114     * @param Closure(): ?string $produce
115     */
116    private function readInto(ResponseDecoder $decoder, Closure $produce): void
117    {
118        $received = 0;
119        while (!$decoder->isComplete()) {
120            $bytes = $produce();
121            if ($bytes === null) {
122                throw new IcapMalformedResponseException(
123                    'Connection closed before the response was complete (got ' . $received . ' bytes).',
124                );
125            }
126            $received += strlen($bytes);
127            $decoder->feed($bytes);
128        }
129
130        if ($decoder->remainder() !== '') {
131            throw new IcapMalformedResponseException('The server sent bytes that no request was waiting for.');
132        }
133    }
134}