Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.00% covered (warning)
88.00%
44 / 50
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChunkedBodyEncoder
88.00% covered (warning)
88.00%
44 / 50
50.00% covered (danger)
50.00%
2 / 4
30.45
0.00% covered (danger)
0.00%
0 / 1
 encode
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
10.80
 encodeRemainderFromStream
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 streamChunks
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 readBlock
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
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\Exception\IcapStorageException;
24
25/**
26 * HTTP/1.1 chunked-transfer encoder used by both the {@see
27 * RequestFormatter} (for the encapsulated body inside an ICAP request)
28 * and by the strict RFC 3507 §4.5 preview-continue path (where the
29 * client sends the rest of the body on the same socket after the
30 * server's `100 Continue` response).
31 *
32 * Strings are emitted as a single chunk; resources are read in
33 * {@see self::CHUNK_SIZE} blocks, and the files the client opens itself
34 * ({@see LocalFile}) in blocks of {@see LocalFile::BLOCK_SIZE}, so a
35 * multi-gigabyte body never needs to reside in memory.
36 *
37 * A stream body ends only at an end the encoder has seen. A read error, a
38 * read that makes no progress before the end, and a stream that has to be
39 * rewound but cannot be all raise an IcapStorageException, and the terminator
40 * (with or without `ieof`) is not written: a source failure must not turn
41 * into a shorter body that looks complete. The same holds for a stream that
42 * does not have the length announced for it.
43 */
44final class ChunkedBodyEncoder
45{
46    public const int CHUNK_SIZE = 8192;
47
48    /**
49     * @param  string|resource|LocalFile $body
50     * @param  int|null                  $length bytes a stream body has to deliver, or null for "until its
51     *                                           end"; a string is its own length
52     * @return iterable<string>
53     *
54     * @throws IcapStorageException When a stream body cannot be rewound to its
55     *         start, cannot be read, stops without reaching its end, or does
56     *         not match `$length`
57     */
58    public function encode(mixed $body, bool $previewIsComplete = false, ?int $length = null): iterable
59    {
60        $terminator = $previewIsComplete ? "0; ieof\r\n\r\n" : "0\r\n\r\n";
61
62        if (is_string($body)) {
63            if ($body !== '') {
64                yield dechex(strlen($body)) . "\r\n" . $body . "\r\n";
65            }
66            yield $terminator;
67            return;
68        }
69
70        if (!is_resource($body) && !$body instanceof LocalFile) {
71            throw new \InvalidArgumentException(
72                'Encapsulated HTTP body must be a string or a readable stream resource.',
73            );
74        }
75
76        // The whole body goes out, so the stream has to stand at its start.
77        // One that was read before must go back; if it cannot, what was
78        // consumed would be missing from the body. A stream still at its
79        // start is not touched, so a non-seekable one works as before. A
80        // LocalFile stands at its start: the client measured or rewound it.
81        if (is_resource($body) && ftell($body) !== 0 && !rewind($body)) {
82            throw new IcapStorageException('The body stream was read before and cannot be rewound to its start.');
83        }
84
85        foreach ($this->streamChunks($body, $length) as $chunk) {
86            yield $chunk;
87        }
88        yield $terminator;
89    }
90
91    /**
92     * Encode the remainder of an already-partially-read stream as
93     * HTTP/1.1 chunked-transfer. Reads from the **current** stream
94     * position (after the preview bytes) without rewinding.
95     *
96     * Used by the strict RFC 3507 §4.5 continuation path: the client
97     * has already sent the preview chunk, received `100 Continue`, and
98     * now streams the rest of the body on the same socket.
99     *
100     * With `$length` the stream has to deliver exactly that many bytes and
101     * then end: a source that ends early, or holds more, is an error and the
102     * terminator is not written. The preview flow passes the size it
103     * announced for the rest.
104     *
105     * @param  resource|LocalFile $stream  readable stream positioned past the preview
106     * @param  int|null           $length  bytes the stream must deliver, or null for "until its end"
107     * @return iterable<string>
108     *
109     * @throws IcapStorageException When the stream cannot be read, stops without
110     *         reaching its end, or does not match `$length`
111     */
112    public function encodeRemainderFromStream(mixed $stream, ?int $length = null): iterable
113    {
114        if (!is_resource($stream) && !$stream instanceof LocalFile) {
115            throw new \InvalidArgumentException(
116                'Expected a readable stream resource.',
117            );
118        }
119
120        foreach ($this->streamChunks($stream, $length) as $chunk) {
121            yield $chunk;
122        }
123        yield "0\r\n\r\n";
124    }
125
126    /**
127     * Chunk-encode a stream from its current position to its end, in
128     * blocks of {@see self::CHUNK_SIZE} (a LocalFile: {@see LocalFile::BLOCK_SIZE}).
129     * With `$length` it has to deliver exactly that many bytes and then end:
130     * at most that many are read, and a source that ends early, or still
131     * holds a byte after them, raises before the caller can write a
132     * terminator.
133     *
134     * The public methods pass its chunks on with `yield`, not `yield from`: the
135     * keys of delegated chunks would collide with the terminator's for a
136     * caller that collects them with `iterator_to_array()`.
137     *
138     * @param  resource|LocalFile $stream
139     * @return iterable<string>
140     *
141     * @throws IcapStorageException When the stream cannot be read, stops without
142     *         reaching its end, or does not match `$length`
143     */
144    private function streamChunks(mixed $stream, ?int $length): iterable
145    {
146        $block = $stream instanceof LocalFile ? LocalFile::BLOCK_SIZE : self::CHUNK_SIZE;
147        $sent = 0;
148        while ($length === null || $sent < $length) {
149            $want = $length === null ? $block : max(1, min($block, $length - $sent));
150            $bytes = $this->readBlock($stream, $want);
151            if ($bytes === null) {
152                break;
153            }
154            $sent += strlen($bytes);
155            yield dechex(strlen($bytes)) . "\r\n" . $bytes . "\r\n";
156        }
157
158        if ($length === null) {
159            return;
160        }
161        if ($sent !== $length) {
162            throw new IcapStorageException(sprintf(
163                'The body stream ended after %d of the %d bytes announced.',
164                $sent,
165                $length,
166            ));
167        }
168        // Everything announced was read; nothing may follow it.
169        if ($this->readBlock($stream, 1) !== null) {
170            throw new IcapStorageException(sprintf(
171                'The body stream holds more than the %d bytes announced.',
172                $length,
173            ));
174        }
175    }
176
177    /**
178     * Read the next block of a stream, or null once its end is confirmed.
179     * Only a stream that reports its end ends the body: `false`, or an empty
180     * read while the stream is not at its end, is a failure and not a
181     * shorter body (B08).
182     *
183     * @param  resource|LocalFile $stream
184     * @param  int<1, max>        $length bytes to ask for
185     *
186     * @throws IcapStorageException When the read fails or makes no progress
187     */
188    private function readBlock(mixed $stream, int $length): ?string
189    {
190        if ($stream instanceof LocalFile) {
191            return $stream->read($length);
192        }
193
194        $block = fread($stream, $length);
195        if ($block === false || ($block === '' && !feof($stream))) {
196            throw new IcapStorageException('The body stream cannot be read to its end.');
197        }
198
199        return $block === '' ? null : $block;
200    }
201}