Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.15% covered (success)
99.15%
233 / 235
93.55% covered (success)
93.55%
29 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResponseDecoder
99.15% covered (success)
99.15%
233 / 235
93.55% covered (success)
93.55%
29 / 31
126
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
6.56
 feed
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 isComplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 frame
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 remainder
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 response
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 workedBytes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 admits
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 assertComplete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 advance
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
 offset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 release
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 readHead
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 endOfHead
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 layout
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
16
 readSection
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 readSize
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 readData
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 readDataEnd
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 readTrailer
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 line
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 complete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 statusOf
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isStartLine
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isBodyless
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 needsLayout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 number
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 tooLarge
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tooManyHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lineTooLong
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 invalidLayout
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\IcapResponse;
24use Ndrstmr\Icap\Exception\IcapMalformedResponseException;
25
26/**
27 * Frames and reads one ICAP response off a stream of byte fragments (RFC 3507
28 * §4.3.3, §4.4). The transports use it to find where a response ends, the
29 * parser to read a complete one; both ask the same code, so a response is
30 * accepted or refused alike, however its bytes arrive.
31 *
32 * The message ends where its layout says, and nowhere else:
33 *
34 *  - The head is the status line and the header lines up to the blank line,
35 *    each ended by CRLF.
36 *  - The `Encapsulated` header names the parts of the body. A response has
37 *    one of the forms RFC 3507 §4.4.1 allows for it: `[req-hdr] req-body`,
38 *    `[res-hdr] res-body` or `opt-body`, any of them with `null-body` instead
39 *    of the body entry. Offsets start at 0, increase, and are decimal numbers
40 *    that fit the size limit. A 100, a 204 and an error status may leave the
41 *    header out and then end at the blank line; every other status needs it.
42 *    A 100 and a 204 carry no message: if they name a layout, it is
43 *    `null-body=0`.
44 *  - A header section is exactly as long as its offsets say, begins with a
45 *    start line, has header lines and ends with a blank line.
46 *  - A body is chunked: hex size, optional extensions, CRLF, exactly that many
47 *    bytes, CRLF; the last chunk is followed by the trailer fields and a blank
48 *    line. Bytes inside a chunk are data, whatever they look like.
49 *
50 * Anything else is an IcapMalformedResponseException, raised as soon as the
51 * bytes that show it have arrived. Numbers are checked against the size limit
52 * digit by digit before they are used, so none of them is ever cast,
53 * multiplied or added past what fits. The limits for the size of the response,
54 * the number of header lines and the length of a line apply to every block of
55 * lines it carries: the head, the header sections, the chunk size lines and
56 * the trailer.
57 *
58 * The decoder keeps its place: each byte is searched and checked a constant
59 * number of times however it is fragmented ({@see workedBytes()}). Bytes that
60 * arrive behind the end of the message are kept apart as {@see remainder()}.
61 *
62 * A decoder that discards ($discard) takes the message in without keeping it:
63 * the bytes it has used are dropped, so it holds at most an unfinished line (the
64 * line length limit) and the fragment it was just given, however large the
65 * body is. It counts the body bytes instead of collecting them, and
66 * `$maxResponseSize` is then the budget for the bytes the whole message may
67 * have; the limits for header lines and their number stay what they are.
68 *
69 * @internal
70 */
71final class ResponseDecoder
72{
73    private const int HEAD = 0;
74    private const int SECTION = 1;
75    private const int SIZE = 2;
76    private const int DATA = 3;
77    private const int DATA_END = 4;
78    private const int TRAILER = 5;
79    private const int DONE = 6;
80
81    /** The entries of an Encapsulated header that name a body. */
82    private const array BODIES = ['req-body', 'res-body', 'opt-body', 'null-body'];
83
84    private const string TOKEN = '[!#$%&\'*+\-.^_`|~0-9A-Za-z]+';
85
86    /** A chunk size line: hex digits and extensions, each a token with an optional token or quoted value. */
87    private const string CHUNK_LINE = '/^([0-9A-Fa-f]+)'
88        . '(?>[ \t]*;[ \t]*' . self::TOKEN
89        . '(?>[ \t]*=[ \t]*(?>' . self::TOKEN . '|"(?>[\t\x20\x21\x23-\x5B\x5D-\x7E\x80-\xFF]|\\\\[\t\x20-\x7E\x80-\xFF])*"))?)*\z/';
90
91    private string $buffer = '';
92    /** Bytes dropped from the front of the buffer, once a discarding decoder has used them. */
93    private int $base = 0;
94    /** Where the bytes the state machine has not used yet begin. */
95    private int $pos = 0;
96    /** Where the next search for CRLF goes on, so that a line is never searched twice. */
97    private int $searchFrom = 0;
98    private int $state = self::HEAD;
99    private int $work = 0;
100
101    /** @var list<string> the status line and the header lines of the head */
102    private array $head = [];
103    private int $status = 0;
104    /** @var array<string, string[]> */
105    private array $headers = [];
106
107    /** 'req', 'res' or '' for a layout without a header section. */
108    private string $sectionKind = '';
109    private int $sectionLength = 0;
110    private int $sectionStart = 0;
111    /** @var list<string> */
112    private array $sectionLines = [];
113    private bool $hasBody = false;
114    /** Status of the encapsulated response (`res-hdr`), once its header section has been read. */
115    private ?int $encapsulatedStatus = null;
116
117    private int $chunkLeft = 0;
118    private string $body = '';
119    /** Decoded body bytes a discarding decoder has passed over. */
120    private int $discarded = 0;
121    private int $trailerFields = 0;
122
123    private string $frame = '';
124    private string $remainder = '';
125
126    /**
127     * @param bool $collectBody whether to assemble the decoded body; framing alone does not need it
128     * @param bool $discard     whether to receive the message without keeping it: neither the frame nor the body
129     *                          (see the class description); excludes $collectBody
130     */
131    public function __construct(
132        private readonly int $maxResponseSize,
133        private readonly int $maxHeaderCount,
134        private readonly int $maxHeaderLineLength,
135        private readonly bool $collectBody = true,
136        private readonly bool $discard = false,
137    ) {
138        if ($maxResponseSize < 1 || $maxHeaderCount < 1 || $maxHeaderLineLength < 1) {
139            throw new \InvalidArgumentException('Decoder limits must be >= 1.');
140        }
141        if ($discard && $collectBody) {
142            throw new \InvalidArgumentException('A decoder that discards the message cannot collect the body.');
143        }
144    }
145
146    /**
147     * Takes the next bytes of the stream. Bytes behind the end of the message are not read as part
148     * of it, now or in a later call.
149     *
150     * @throws IcapMalformedResponseException When the bytes so far cannot belong to a legal response
151     */
152    public function feed(string $bytes): void
153    {
154        if ($this->state === self::DONE) {
155            $this->remainder .= $bytes;
156
157            return;
158        }
159
160        $this->buffer .= $bytes;
161        $this->advance();
162
163        // Not complete, and already more than the response may have.
164        if ($this->state !== self::DONE && $this->base + strlen($this->buffer) > $this->maxResponseSize) {
165            throw $this->tooLarge();
166        }
167        $this->release();
168    }
169
170    public function isComplete(): bool
171    {
172        return $this->state === self::DONE;
173    }
174
175    /**
176     * The bytes of the message, from the first byte to the last, exactly.
177     */
178    public function frame(): string
179    {
180        $this->assertComplete();
181        if ($this->discard) {
182            throw new \LogicException('The decoder was built to discard the message, not to keep its frame.');
183        }
184
185        return $this->frame;
186    }
187
188    /**
189     * The bytes that came behind the message: the start of what follows, or bytes that no
190     * request is waiting for.
191     */
192    public function remainder(): string
193    {
194        $this->assertComplete();
195
196        return $this->remainder;
197    }
198
199    /**
200     * Status, headers, the status of the encapsulated response and the decoded body of the message; for a decoder
201     * that discards, the body is empty and its length is in {@see IcapResponse::$discardedBodyBytes}.
202     */
203    public function response(): IcapResponse
204    {
205        $this->assertComplete();
206        if ($this->discard) {
207            return new IcapResponse($this->status, $this->headers, '', $this->discarded, $this->encapsulatedStatus);
208        }
209        if (!$this->collectBody) {
210            throw new \LogicException('The decoder was built to frame, not to collect the body.');
211        }
212
213        return new IcapResponse($this->status, $this->headers, $this->body, null, $this->encapsulatedStatus);
214    }
215
216    /**
217     * How many bytes the decoder has searched, checked or copied so far. It grows with the bytes
218     * of the message, not with the number of fragments they came in.
219     */
220    public function workedBytes(): int
221    {
222        return $this->work;
223    }
224
225    /**
226     * Whether a decoder with these limits takes the response written out the way a server sends it: `ICAP/1.0 <status> OK`,
227     * a line `Name: value` for every field value, the blank line and, if the `Encapsulated` layout names a body
228     * (`null-body` names none) or there is one, the body as one chunk and the last chunk, which is all of an empty body. A
229     * header section of the layout is left out, as the response does not hold it; the OPTIONS caches keep no such response.
230     * For a response that comes from somewhere else than the wire (a cache) and has to meet the limits of the client that
231     * reads it, as it would have from the server. A server that writes the same response with other bytes (a longer reason
232     * phrase, no space after the colon, a body in several chunks, chunk extensions, a trailer) differs from this by a few.
233     */
234    public static function admits(IcapResponse $response, int $maxResponseSize, int $maxHeaderCount, int $maxHeaderLineLength): bool
235    {
236        $lines = ['ICAP/1.0 ' . $response->statusCode . ' OK'];
237        foreach ($response->headers as $name => $values) {
238            foreach ($values as $value) {
239                $lines[] = $name . ': ' . $value;
240            }
241        }
242        // The status line is no header line.
243        if (count($lines) > $maxHeaderCount + 1) {
244            return false;
245        }
246
247        // Every line ends in CRLF, as does the blank line behind them.
248        $bytes = 2;
249        foreach ($lines as $line) {
250            $length = strlen($line);
251            if ($length > $maxHeaderLineLength) {
252                return false;
253            }
254            $bytes += $length + 2;
255        }
256        $data = strlen($response->body);
257        $layout = implode(',', HeaderFields::values($response->headers, 'Encapsulated'));
258        if ($data > 0 || preg_match('/(?:\A|,)[ \t]*(?:req|res|opt)-body[ \t]*=/i', $layout) === 1) {
259            // The last chunk with the blank line of the trailer, behind the data as one chunk: size line, data, CRLF.
260            $bytes += 5 + ($data > 0 ? strlen(dechex($data)) + 2 + $data + 2 : 0);
261        }
262
263        return $bytes <= $maxResponseSize;
264    }
265
266    private function assertComplete(): void
267    {
268        if ($this->state !== self::DONE) {
269            throw new \LogicException('The ICAP response is not complete yet.');
270        }
271    }
272
273    private function advance(): void
274    {
275        do {
276            $progress = match ($this->state) {
277                self::HEAD => $this->readHead(),
278                self::SECTION => $this->readSection(),
279                self::SIZE => $this->readSize(),
280                self::DATA => $this->readData(),
281                self::DATA_END => $this->readDataEnd(),
282                self::TRAILER => $this->readTrailer(),
283                default => false,
284            };
285
286            if ($this->offset() > $this->maxResponseSize) {
287                throw $this->tooLarge();
288            }
289        } while ($progress);
290    }
291
292    /**
293     * Where the bytes the state machine has not used yet begin, counted from the first byte of the message.
294     */
295    private function offset(): int
296    {
297        return $this->base + $this->pos;
298    }
299
300    /**
301     * Drops the bytes a discarding decoder has used, so that what it holds does not grow with the message.
302     */
303    private function release(): void
304    {
305        if (!$this->discard || $this->pos === 0 || $this->state === self::DONE) {
306            return;
307        }
308
309        $this->base += $this->pos;
310        $this->buffer = substr($this->buffer, $this->pos);
311        $this->searchFrom = max(0, $this->searchFrom - $this->pos);
312        $this->work += strlen($this->buffer);
313        $this->pos = 0;
314    }
315
316    private function readHead(): bool
317    {
318        $line = $this->line();
319        if ($line === null) {
320            return false;
321        }
322
323        if ($this->head === []) {
324            $this->status = $this->statusOf($line);
325            $this->head[] = $line;
326
327            return true;
328        }
329
330        if ($line !== '') {
331            $this->head[] = $line;
332            if (count($this->head) > $this->maxHeaderCount + 1) {
333                throw $this->tooManyHeaders();
334            }
335
336            return true;
337        }
338
339        $this->endOfHead();
340
341        return $this->state !== self::DONE;
342    }
343
344    private function endOfHead(): void
345    {
346        // A line without a colon is refused here, before the layout is looked at.
347        $this->headers = HeaderFields::parse(array_slice($this->head, 1));
348        $encapsulated = HeaderFields::single($this->headers, 'Encapsulated');
349
350        if ($encapsulated === null) {
351            if (self::needsLayout($this->status)) {
352                throw new IcapMalformedResponseException('The ICAP response lacks its Encapsulated header.');
353            }
354            $this->complete();
355
356            return;
357        }
358
359        $this->layout($encapsulated);
360        if (self::isBodyless($this->status) && ($this->sectionKind !== '' || $this->hasBody)) {
361            // Refused with the head, before a byte of the message is read or answered.
362            throw new IcapMalformedResponseException('A 100 or 204 ICAP response carries no encapsulated message.');
363        }
364        if ($this->sectionKind !== '') {
365            $this->sectionStart = $this->offset();
366            $this->state = self::SECTION;
367        } elseif ($this->hasBody) {
368            $this->state = self::SIZE;
369        } else {
370            $this->complete();
371        }
372    }
373
374    /**
375     * RFC 3507 §4.4.1 for a response: [req-hdr] req-body, [res-hdr] res-body or opt-body, each
376     * with null-body in place of the body. Sets the header section (kind and length) and
377     * whether a chunked body follows.
378     */
379    private function layout(string $value): void
380    {
381        $entries = [];
382        foreach (explode(',', $value) as $part) {
383            if (preg_match('/^[ \t]*([A-Za-z]+-[A-Za-z]+)=([0-9]+)[ \t]*\z/', $part, $m) !== 1) {
384                throw $this->invalidLayout();
385            }
386            $name = strtolower($m[1]);
387            if (isset($entries[$name])) {
388                throw $this->invalidLayout();
389            }
390            $entries[$name] = $this->number($m[2], 10, $this->maxResponseSize);
391        }
392
393        $names = array_keys($entries);
394        $offsets = array_values($entries);
395
396        if (count($names) === 1 && in_array($names[0], self::BODIES, true) && $offsets[0] === 0) {
397            $this->sectionKind = '';
398            $this->hasBody = $names[0] !== 'null-body';
399
400            return;
401        }
402
403        if (count($names) === 2 && $offsets[0] === 0 && $offsets[1] > 0) {
404            $body = match ($names[0]) {
405                'req-hdr' => 'req-body',
406                'res-hdr' => 'res-body',
407                default => null,
408            };
409            if ($body !== null && ($names[1] === $body || $names[1] === 'null-body')) {
410                $this->sectionKind = substr($names[0], 0, 3);
411                $this->sectionLength = $offsets[1];
412                $this->hasBody = $names[1] !== 'null-body';
413
414                return;
415            }
416        }
417
418        throw $this->invalidLayout();
419    }
420
421    /**
422     * An encapsulated header section: a start line, header lines and a blank line, and the blank
423     * line is the last byte of the length the layout gave the section.
424     */
425    private function readSection(): bool
426    {
427        $line = $this->line();
428        if ($line === null) {
429            return false;
430        }
431
432        if ($this->sectionLines === [] && !$this->isStartLine($line)) {
433            throw new IcapMalformedResponseException('Invalid start line in the encapsulated headers of the ICAP response.');
434        }
435
436        if ($line !== '') {
437            $this->sectionLines[] = $line;
438            if (count($this->sectionLines) > $this->maxHeaderCount + 1) {
439                throw $this->tooManyHeaders();
440            }
441
442            return true;
443        }
444
445        if ($this->offset() !== $this->sectionStart + $this->sectionLength) {
446            throw new IcapMalformedResponseException('The encapsulated headers of the ICAP response do not end where its Encapsulated header says.');
447        }
448        HeaderFields::parse(array_slice($this->sectionLines, 1));
449        if ($this->sectionKind === 'res') {
450            // The start line passed isStartLine(): "HTTP/x.y " and three digits.
451            $this->encapsulatedStatus = (int) substr($this->sectionLines[0], 9, 3);
452        }
453        $this->sectionLines = [];
454
455        if (!$this->hasBody) {
456            $this->complete();
457
458            return false;
459        }
460        $this->state = self::SIZE;
461
462        return true;
463    }
464
465    private function readSize(): bool
466    {
467        $line = $this->line();
468        if ($line === null) {
469            return false;
470        }
471
472        if (preg_match(self::CHUNK_LINE, $line, $m) !== 1) {
473            throw new IcapMalformedResponseException('Invalid chunk size line in the ICAP response.');
474        }
475        // What is left for the data and the CRLF behind it; a size that cannot fit is refused now.
476        $size = $this->number($m[1], 16, max(0, $this->maxResponseSize - $this->offset() - 2));
477
478        if ($size === 0) {
479            $this->state = self::TRAILER;
480        } else {
481            $this->chunkLeft = $size;
482            $this->state = self::DATA;
483        }
484
485        return true;
486    }
487
488    private function readData(): bool
489    {
490        $take = min(strlen($this->buffer) - $this->pos, $this->chunkLeft);
491        if ($take > 0) {
492            if ($this->discard) {
493                $this->discarded += $take;
494            } elseif ($this->collectBody) {
495                $this->body .= substr($this->buffer, $this->pos, $take);
496                $this->work += $take;
497            }
498            $this->pos += $take;
499            $this->chunkLeft -= $take;
500        }
501        if ($this->chunkLeft > 0) {
502            return false;
503        }
504
505        $this->state = self::DATA_END;
506
507        return true;
508    }
509
510    private function readDataEnd(): bool
511    {
512        $available = strlen($this->buffer) - $this->pos;
513        if ($available >= 1 && $this->buffer[$this->pos] !== "\r") {
514            throw new IcapMalformedResponseException('Invalid chunk in the ICAP response: no CRLF behind the chunk data.');
515        }
516        if ($available < 2) {
517            return false;
518        }
519        if ($this->buffer[$this->pos + 1] !== "\n") {
520            throw new IcapMalformedResponseException('Invalid chunk in the ICAP response: no CRLF behind the chunk data.');
521        }
522
523        $this->pos += 2;
524        $this->work += 2;
525        $this->state = self::SIZE;
526
527        return true;
528    }
529
530    private function readTrailer(): bool
531    {
532        $line = $this->line();
533        if ($line === null) {
534            return false;
535        }
536
537        if ($line === '') {
538            $this->complete();
539
540            return false;
541        }
542
543        if (preg_match('/^' . self::TOKEN . ':[\t\x20-\x7E\x80-\xFF]*\z/', $line) !== 1) {
544            throw new IcapMalformedResponseException('Invalid trailer field in the ICAP response.');
545        }
546        if (++$this->trailerFields > $this->maxHeaderCount) {
547            throw $this->tooManyHeaders();
548        }
549
550        return true;
551    }
552
553    /**
554     * The next line without its CRLF, or null while it is not complete. A line ends at CRLF only:
555     * a CR or LF of its own would be a line end for some readers and content for others.
556     */
557    private function line(): ?string
558    {
559        $from = max($this->searchFrom, $this->pos);
560        $end = $from > strlen($this->buffer) ? false : strpos($this->buffer, "\r\n", $from);
561
562        if ($end === false) {
563            $this->work += max(0, strlen($this->buffer) - $from);
564            // A CR at the very end may be the first half of the CRLF; the next search starts there.
565            $this->searchFrom = max($this->pos, strlen($this->buffer) - 1);
566            if (strlen($this->buffer) - $this->pos > $this->maxHeaderLineLength + 1) {
567                throw $this->lineTooLong();
568            }
569
570            return null;
571        }
572
573        $length = $end - $this->pos;
574        $this->work += $end + 2 - $from;
575        if ($length > $this->maxHeaderLineLength) {
576            throw $this->lineTooLong();
577        }
578
579        $line = substr($this->buffer, $this->pos, $length);
580        $this->work += 2 * $length;
581        if (strpbrk($line, "\r\n") !== false) {
582            throw new IcapMalformedResponseException('Invalid line ending in the ICAP response.');
583        }
584
585        $this->pos = $end + 2;
586        $this->searchFrom = $this->pos;
587
588        return $line;
589    }
590
591    private function complete(): void
592    {
593        if (!$this->discard) {
594            $this->frame = substr($this->buffer, 0, $this->pos);
595            $this->work += $this->pos;
596        }
597        $this->remainder = substr($this->buffer, $this->pos);
598        $this->buffer = '';
599        $this->head = [];
600        $this->state = self::DONE;
601    }
602
603    private function statusOf(string $line): int
604    {
605        if (preg_match('/^ICAP\/1\.[0-9] ([0-9]{3})(?: (.*))?\z/', $line, $m) !== 1 || !HeaderFields::isText($m[2] ?? '')) {
606            // What the server sent stays out of the message: it would end up in logs and in handlers.
607            throw new IcapMalformedResponseException('Invalid status line in the ICAP response.');
608        }
609
610        return (int) $m[1];
611    }
612
613    private function isStartLine(string $line): bool
614    {
615        $pattern = $this->sectionKind === 'req'
616            ? '/^' . self::TOKEN . ' [^\x00-\x20\x7F]+ HTTP\/[0-9]\.[0-9]\z/'
617            : '/^HTTP\/[0-9]\.[0-9] [0-9]{3}(?: [\t\x20-\x7E\x80-\xFF]*)?\z/';
618
619        return preg_match($pattern, $line) === 1;
620    }
621
622    /**
623     * "Send the rest" and "no change" are answers by themselves: no message follows.
624     */
625    private static function isBodyless(int $status): bool
626    {
627        return $status === 100 || $status === 204;
628    }
629
630    /**
631     * Only a bodyless status or an error may do without the header that says where the message ends.
632     */
633    private static function needsLayout(int $status): bool
634    {
635        return !(self::isBodyless($status) || ($status >= 400 && $status <= 599));
636    }
637
638    /**
639     * A number from its digits, refused as soon as it is more than $limit: before the digit that
640     * would take it over is multiplied in, so nothing is ever cast or computed past what fits.
641     *
642     * @param non-empty-string $digits only the digits of the base
643     */
644    private function number(string $digits, int $base, int $limit): int
645    {
646        $value = 0;
647        for ($i = 0, $length = strlen($digits); $i < $length; $i++) {
648            $digit = intval($digits[$i], $base);
649            if ($digit > $limit || $value > intdiv($limit - $digit, $base)) {
650                throw $this->tooLarge();
651            }
652            $value = $value * $base + $digit;
653        }
654        $this->work += strlen($digits);
655
656        return $value;
657    }
658
659    private function tooLarge(): IcapMalformedResponseException
660    {
661        return new IcapMalformedResponseException(sprintf('ICAP response exceeded max size (%d bytes).', $this->maxResponseSize));
662    }
663
664    private function tooManyHeaders(): IcapMalformedResponseException
665    {
666        return new IcapMalformedResponseException(sprintf('ICAP response exceeded max header count (%d).', $this->maxHeaderCount));
667    }
668
669    private function lineTooLong(): IcapMalformedResponseException
670    {
671        return new IcapMalformedResponseException(sprintf('ICAP response line exceeded the max line length (%d).', $this->maxHeaderLineLength));
672    }
673
674    private function invalidLayout(): IcapMalformedResponseException
675    {
676        return new IcapMalformedResponseException('Invalid Encapsulated header in the ICAP response.');
677    }
678}