Skip to content

Commit 463b937

Browse files
authored
feat(io): added Reader::readUntilBounded() method (#620)
1 parent ebcc123 commit 463b937

File tree

7 files changed

+249
-0
lines changed

7 files changed

+249
-0
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 5.5.0
4+
5+
### features
6+
7+
- feat(io): added `Reader::readUntilBounded(string $suffix, int $max_bytes, ?Duration $timeout)` method, which reads until a suffix is found, but throws `IO\Exception\OverflowException` if the content exceeds `$max_bytes` before the suffix is encountered.
8+
- feat(io): added `IO\Exception\OverflowException` exception class.
9+
310
## 5.4.0
411

512
### features

docs/content/io/io.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ In non-CLI SAPIs, `input_handle()` reads from `php://input` and `output_handle()
4141

4242
@example('io/io-reader.php')
4343

44+
### Bounded Reads
45+
46+
`Reader::readUntilBounded()` works like `readUntil()` but enforces a maximum byte limit. If the suffix is not found within `$max_bytes`, an `IO\Exception\OverflowException` is thrown. This prevents unbounded memory consumption when reading from untrusted sources — for example, capping HTTP header lines to a safe size so a malicious client cannot exhaust memory by sending an endless line.
47+
48+
@example('io/io-reader-bounded.php')
49+
4450
## Spool
4551

4652
`IO\spool()` creates a handle that writes to memory until a threshold is reached (default 2MB), then transparently spools to a temporary file on disk. This is useful when buffering data of unknown size without risking excessive memory usage.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require_once __DIR__ . '/../../../vendor/autoload.php';
6+
7+
use Psl\IO;
8+
9+
$handle = new IO\MemoryHandle("GET / HTTP/1.1\r\nHost: example.com\r\n\r\nbody");
10+
$reader = new IO\Reader($handle);
11+
12+
// Read the request line, capped at 8192 bytes
13+
$requestLine = $reader->readUntilBounded("\r\n", 8192);
14+
IO\write_line('Request line: %s', $requestLine ?? '<not found>');
15+
16+
// Read a header line, capped at 4096 bytes
17+
$header = $reader->readUntilBounded("\r\n", 4096);
18+
IO\write_line('Header: %s', $header ?? '<not found>');
19+
20+
// If the line exceeds the limit, OverflowException is thrown:
21+
$tinyHandle = new IO\MemoryHandle("this line is way too long\r\n");
22+
$tinyReader = new IO\Reader($tinyHandle);
23+
24+
try {
25+
$tinyReader->readUntilBounded("\r\n", 5);
26+
} catch (IO\Exception\OverflowException $e) {
27+
IO\write_line('Caught: %s', $e->getMessage());
28+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psl\IO\Exception;
6+
7+
use Psl\Exception;
8+
9+
class OverflowException extends Exception\OverflowException implements ExceptionInterface {}

src/Psl/IO/Reader.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,97 @@ public function readUntil(string $suffix, null|Duration $timeout = null): null|s
207207
return substr($buf, 0, $idx);
208208
}
209209

210+
/**
211+
* Read until the specified suffix is seen, with a maximum number of bytes to read.
212+
*
213+
* The trailing suffix is read (so won't be returned by other calls), but is not
214+
* included in the return value.
215+
*
216+
* This call returns null if the suffix is not seen before EOF.
217+
*
218+
* @param positive-int $max_bytes Maximum number of bytes to read before throwing OverflowException.
219+
*
220+
* @throws Exception\AlreadyClosedException If the handle has been already closed.
221+
* @throws Exception\RuntimeException If an error occurred during the operation.
222+
* @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
223+
* @throws Exception\OverflowException If $max_bytes is exceeded without finding the suffix.
224+
*/
225+
public function readUntilBounded(string $suffix, int $max_bytes, null|Duration $timeout = null): null|string
226+
{
227+
$buf = $this->buffer;
228+
$suffix_len = strlen($suffix);
229+
$idx = strpos($buf, $suffix);
230+
if (false !== $idx) {
231+
if ($idx > $max_bytes) {
232+
throw new Exception\OverflowException(Str\format(
233+
'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
234+
$max_bytes,
235+
$suffix,
236+
));
237+
}
238+
239+
$this->buffer = substr($buf, $idx + $suffix_len);
240+
return substr($buf, 0, $idx);
241+
}
242+
243+
if (strlen($buf) > $max_bytes) {
244+
throw new Exception\OverflowException(Str\format(
245+
'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
246+
$max_bytes,
247+
$suffix,
248+
));
249+
}
250+
251+
$timer = new Async\OptionalIncrementalTimeout($timeout, static function () use ($suffix): void {
252+
// @codeCoverageIgnoreStart
253+
throw new Exception\TimeoutException(Str\format(
254+
"Reached timeout before encountering the suffix (\"%s\").",
255+
$suffix,
256+
));
257+
// @codeCoverageIgnoreEnd
258+
});
259+
260+
do {
261+
$offset = strlen($buf) - $suffix_len + 1;
262+
$offset = $offset > 0 ? $offset : 0;
263+
$chunk = $this->handle->read(null, $timer->getRemaining());
264+
if ('' === $chunk) {
265+
$this->buffer = $buf;
266+
return null;
267+
}
268+
269+
$buf .= $chunk;
270+
$idx = strpos($buf, $suffix, $offset);
271+
272+
if (false !== $idx) {
273+
if ($idx > $max_bytes) {
274+
$this->buffer = $buf;
275+
throw new Exception\OverflowException(Str\format(
276+
'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
277+
$max_bytes,
278+
$suffix,
279+
));
280+
}
281+
282+
break;
283+
}
284+
285+
if (strlen($buf) > $max_bytes) {
286+
$this->buffer = $buf;
287+
throw new Exception\OverflowException(Str\format(
288+
'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
289+
$max_bytes,
290+
$suffix,
291+
));
292+
}
293+
} while (true);
294+
295+
/** @var int<0, max> $idx*/
296+
$this->buffer = substr($buf, $idx + $suffix_len);
297+
298+
return substr($buf, 0, $idx);
299+
}
300+
210301
/**
211302
* {@inheritDoc}
212303
*/

src/Psl/Internal/Loader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,6 +1093,7 @@ final class Loader
10931093
'Psl\\Filesystem\\Exception\\NotReadableException' => 'Psl/Filesystem/Exception/NotReadableException.php',
10941094
'Psl\\IO\\Exception\\AlreadyClosedException' => 'Psl/IO/Exception/AlreadyClosedException.php',
10951095
'Psl\\IO\\Exception\\RuntimeException' => 'Psl/IO/Exception/RuntimeException.php',
1096+
'Psl\\IO\\Exception\\OverflowException' => 'Psl/IO/Exception/OverflowException.php',
10961097
'Psl\\IO\\Exception\\TimeoutException' => 'Psl/IO/Exception/TimeoutException.php',
10971098
'Psl\\IO\\Internal\\ResourceHandle' => 'Psl/IO/Internal/ResourceHandle.php',
10981099
'Psl\\IO\\Reader' => 'Psl/IO/Reader.php',

tests/unit/IO/ReaderTest.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,113 @@ public function testReadSome(): void
9494
static::assertSame('', $reader->read());
9595
}
9696

97+
public function testReadUntilBoundedFindsPrefix(): void
98+
{
99+
$handle = new IO\MemoryHandle('hello\r\nworld');
100+
$reader = new IO\Reader($handle);
101+
102+
static::assertSame('hello', $reader->readUntilBounded('\r\n', 100));
103+
static::assertSame('world', $reader->read());
104+
}
105+
106+
public function testReadUntilBoundedReturnsNullOnEof(): void
107+
{
108+
$handle = new IO\MemoryHandle('hello');
109+
$reader = new IO\Reader($handle);
110+
111+
static::assertNull($reader->readUntilBounded('@', 100));
112+
}
113+
114+
public function testReadUntilBoundedThrowsOverflowWhenBufferExceedsMaxBeforeRead(): void
115+
{
116+
$handle = new IO\MemoryHandle('aaaaaaaaaa\r\n');
117+
$reader = new IO\Reader($handle);
118+
119+
$this->expectException(IO\Exception\OverflowException::class);
120+
$this->expectExceptionMessage('Exceeded maximum byte limit (5) before encountering the suffix ("\r\n").');
121+
122+
$reader->readUntilBounded('\r\n', 5);
123+
}
124+
125+
public function testReadUntilBoundedThrowsOverflowWhenSuffixFoundBeyondMax(): void
126+
{
127+
$handle = new IO\MemoryHandle('abcdefghij:end');
128+
$reader = new IO\Reader($handle);
129+
130+
$this->expectException(IO\Exception\OverflowException::class);
131+
$this->expectExceptionMessage('Exceeded maximum byte limit (3) before encountering the suffix (":end").');
132+
133+
$reader->readUntilBounded(':end', 3);
134+
}
135+
136+
public function testReadUntilBoundedExactMaxBytes(): void
137+
{
138+
$handle = new IO\MemoryHandle('abcde:end');
139+
$reader = new IO\Reader($handle);
140+
141+
static::assertSame('abcde', $reader->readUntilBounded(':end', 5));
142+
}
143+
144+
public function testReadUntilBoundedSuffixAtStart(): void
145+
{
146+
$handle = new IO\MemoryHandle(':endrest');
147+
$reader = new IO\Reader($handle);
148+
149+
static::assertSame('', $reader->readUntilBounded(':end', 10));
150+
static::assertSame('rest', $reader->read());
151+
}
152+
153+
public function testReadUntilBoundedMultipleSuffixes(): void
154+
{
155+
$handle = new IO\MemoryHandle('ab|cd|ef');
156+
$reader = new IO\Reader($handle);
157+
158+
static::assertSame('ab', $reader->readUntilBounded('|', 100));
159+
static::assertSame('cd', $reader->readUntilBounded('|', 100));
160+
static::assertNull($reader->readUntilBounded('|', 100));
161+
}
162+
163+
public function testReadUntilBoundedPreservesBufferAfterOverflow(): void
164+
{
165+
$handle = new IO\MemoryHandle('toolongcontent:endfoo');
166+
$reader = new IO\Reader($handle);
167+
168+
try {
169+
$reader->readUntilBounded(':end', 3);
170+
static::fail('Expected OverflowException');
171+
} catch (IO\Exception\OverflowException) {
172+
static::addToAssertionCount(1);
173+
}
174+
175+
static::assertSame('toolongcontent:endfoo', $reader->read());
176+
}
177+
178+
public function testReadUntilBoundedSingleByteSuffix(): void
179+
{
180+
$handle = new IO\MemoryHandle('abc\ndef');
181+
$reader = new IO\Reader($handle);
182+
183+
static::assertSame('abc', $reader->readUntilBounded('\n', 10));
184+
static::assertSame('def', $reader->read());
185+
}
186+
187+
public function testReadUntilBoundedEmptyHandle(): void
188+
{
189+
$handle = new IO\MemoryHandle('');
190+
$reader = new IO\Reader($handle);
191+
192+
static::assertNull($reader->readUntilBounded(':end', 100));
193+
}
194+
195+
public function testReadUntilBoundedMaxBytesExactlyAtSuffix(): void
196+
{
197+
$handle = new IO\MemoryHandle('abc|rest');
198+
$reader = new IO\Reader($handle);
199+
200+
static::assertSame('abc', $reader->readUntilBounded('|', 3));
201+
static::assertSame('rest', $reader->read());
202+
}
203+
97204
public function testReadUntilInvalidSuffix(): void
98205
{
99206
$handle = new IO\MemoryHandle('hello');

0 commit comments

Comments
 (0)