Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.00% |
44 / 50 |
|
50.00% |
2 / 4 |
CRAP | |
0.00% |
0 / 1 |
| ChunkedBodyEncoder | |
88.00% |
44 / 50 |
|
50.00% |
2 / 4 |
30.45 | |
0.00% |
0 / 1 |
| encode | |
80.00% |
12 / 15 |
|
0.00% |
0 / 1 |
10.80 | |||
| encodeRemainderFromStream | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
5.26 | |||
| streamChunks | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
9 | |||
| readBlock | |
100.00% |
6 / 6 |
|
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 | |
| 19 | declare(strict_types=1); |
| 20 | |
| 21 | namespace Ndrstmr\Icap; |
| 22 | |
| 23 | use 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 | */ |
| 44 | final 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 | } |