1
0
Fork 0
mirror of https://github.com/shufflingpixels/php-io.git synced 2026-08-15 19:58:14 +02:00

Add LimitedResource for scoped stream windows

Wraps any seekable StreamInterface and exposes a fixed byte range
(start offset + length) as an independent stream. Useful for passing
a subsection of a resource to BinaryReader without copying bytes.
This commit is contained in:
Henrik Hautakoski 2026-06-26 09:31:52 +02:00
parent 89525294de
commit f669183060
2 changed files with 502 additions and 0 deletions

168
src/LimitedResource.php Normal file
View file

@ -0,0 +1,168 @@
<?php
namespace Shufflingpixels\IO;
use InvalidArgumentException;
use OutOfBoundsException;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
use Shufflingpixels\IO\Exception\IOException;
/**
*
*/
class LimitedResource implements StreamInterface
{
private int $position = 0;
private bool $detached = false;
public function __construct(
private StreamInterface $stream,
private int $start,
private int $length
) {
if (!$stream->isSeekable()) {
throw new IOException('Underlying stream must be seekable');
}
if ($start < 0) {
throw new IOException('Start offset must be >= 0');
}
if ($length < 0) {
throw new IOException('Length must be >= 0');
}
}
public function close(): void
{
$this->detach();
}
public function detach(): mixed
{
if ($this->detached) {
return null;
}
$this->detached = true;
$this->position = 0;
return null;
}
public function getSize(): ?int
{
return $this->detached ? null : $this->length;
}
public function eof(): bool
{
return $this->detached || $this->position >= $this->length;
}
public function tell(): int
{
$this->ensureAttached();
return $this->position;
}
public function isSeekable(): bool
{
return true;
}
public function seek(int $offset, int $whence = SEEK_SET): void
{
$this->ensureAttached();
$position = match ($whence) {
SEEK_SET => $offset,
SEEK_CUR => $this->position + $offset,
SEEK_END => $this->length + $offset,
default => throw new InvalidArgumentException('Invalid seek mode'),
};
if ($position < 0 || $position > $this->length) {
throw new OutOfBoundsException("Seek position out of bounds: {$position}");
}
$this->position = $position;
}
public function rewind(): void
{
$this->seek(0);
}
public function isReadable(): bool
{
return true;
}
public function read(int $length): string
{
$this->ensureAttached();
if ($length < 0) {
throw new InvalidArgumentException('Length must be >= 0');
}
if ($length === 0 || $this->eof()) {
return '';
}
$toRead = min($length, $this->length - $this->position);
$this->stream->seek($this->start + $this->position);
$data = $this->stream->read($toRead);
$this->position += \strlen($data);
return $data;
}
public function isWritable(): bool
{
return false;
}
public function write(string $string): int
{
throw new IOException('LimitedResource is read-only');
}
public function getContents(): string
{
$this->ensureAttached();
if ($this->eof()) {
return '';
}
return $this->read($this->length - $this->position);
}
public function getMetadata(?string $key = null): mixed
{
return null;
}
public function __toString(): string
{
if ($this->detached) {
return '';
}
try {
$this->seek(0);
return $this->getContents();
} catch (\Throwable) {
return '';
}
}
private function ensureAttached(): void
{
if ($this->detached) {
throw new RuntimeException('Stream is detached');
}
}
}

View file

@ -0,0 +1,334 @@
<?php
use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\Buffer;
use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\LimitedResource;
// --- Constructor validation ---
it('throws IOException when underlying stream is not seekable', function () {
$stream = new class implements \Psr\Http\Message\StreamInterface {
public function isSeekable(): bool { return false; }
public function close(): void {}
public function detach(): mixed { return null; }
public function getSize(): ?int { return null; }
public function eof(): bool { return true; }
public function tell(): int { return 0; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isReadable(): bool { return false; }
public function read(int $length): string { return ''; }
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function getContents(): string { return ''; }
public function getMetadata(?string $key = null): mixed { return null; }
public function __toString(): string { return ''; }
};
expect(fn() => new LimitedResource($stream, 0, 5))->toThrow(IOException::class);
});
it('throws IOException for negative start offset', function () {
expect(fn() => new LimitedResource(new Buffer('hello'), -1, 5))->toThrow(IOException::class);
});
it('throws IOException for negative length', function () {
expect(fn() => new LimitedResource(new Buffer('hello'), 0, -1))->toThrow(IOException::class);
});
// --- Basic reads & position tracking ---
it('reads the correct bytes from a scoped window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(5))->toBe('World')
->and($limited->tell())->toBe(5)
->and($limited->eof())->toBeTrue();
});
it('advances position correctly across partial reads', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(3))->toBe('Wor')
->and($limited->tell())->toBe(3);
expect($limited->read(2))->toBe('ld')
->and($limited->tell())->toBe(5);
});
it('clamps read to window boundary without throwing', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(9999))->toBe('World')
->and($limited->eof())->toBeTrue();
});
it('returns empty string when already at eof', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(5);
expect($limited->read(1))->toBe('');
});
it('returns empty string for zero-length read', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(0))->toBe('');
expect($limited->tell())->toBe(0);
});
it('throws InvalidArgumentException for negative read length', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect(fn() => $limited->read(-1))->toThrow(InvalidArgumentException::class);
});
// --- Seek modes ---
it('supports SEEK_SET within the window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(2);
expect($limited->tell())->toBe(2)
->and($limited->read(3))->toBe('rld');
});
it('supports SEEK_CUR within the window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(3);
$limited->seek(-1, SEEK_CUR);
expect($limited->tell())->toBe(2)
->and($limited->read(1))->toBe('r');
});
it('supports SEEK_END within the window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(-2, SEEK_END);
expect($limited->tell())->toBe(3)
->and($limited->read(2))->toBe('ld');
});
it('allows seeking to exactly the window length (eof position)', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(5);
expect($limited->tell())->toBe(5)
->and($limited->eof())->toBeTrue();
});
it('throws OutOfBoundsException for seek past window end', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect(fn() => $limited->seek(6))->toThrow(OutOfBoundsException::class);
});
it('throws OutOfBoundsException for negative seek position', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect(fn() => $limited->seek(-1, SEEK_SET))->toThrow(OutOfBoundsException::class);
});
it('throws InvalidArgumentException for invalid seek whence', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect(fn() => $limited->seek(0, 99))->toThrow(InvalidArgumentException::class);
});
// --- getSize / eof / rewind / getContents / __toString ---
it('getSize returns window length regardless of underlying stream size', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->getSize())->toBe(5);
});
it('isSeekable returns true', function () {
expect((new LimitedResource(new Buffer('hello'), 0, 5))->isSeekable())->toBeTrue();
});
it('isReadable returns true', function () {
expect((new LimitedResource(new Buffer('hello'), 0, 5))->isReadable())->toBeTrue();
});
it('rewind resets position to zero', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(3);
$limited->rewind();
expect($limited->tell())->toBe(0)
->and($limited->read(5))->toBe('World');
});
it('getContents returns remaining window content from current position', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(2);
expect($limited->getContents())->toBe('rld')
->and($limited->getContents())->toBe('');
});
it('__toString returns full window content regardless of current position', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(3);
expect((string) $limited)->toBe('World');
});
// --- Zero-length window ---
it('handles zero-length window: immediate eof, reads return empty', function () {
$limited = new LimitedResource(new Buffer('hello'), 3, 0);
expect($limited->getSize())->toBe(0)
->and($limited->eof())->toBeTrue()
->and($limited->read(5))->toBe('')
->and($limited->getContents())->toBe('');
});
it('zero-length window allows SEEK_END with offset 0 without throwing', function () {
$limited = new LimitedResource(new Buffer('hello'), 3, 0);
$limited->seek(0, SEEK_END);
expect($limited->tell())->toBe(0);
});
// --- Lifecycle ---
it('close does not close the underlying stream', function () {
$buffer = new Buffer('Hello, World!');
$limited = new LimitedResource($buffer, 7, 5);
$limited->close();
expect($buffer->read(5))->toBe('Hello');
});
it('detach returns null', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect($limited->detach())->toBeNull();
});
it('getSize returns null after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect($limited->getSize())->toBeNull();
});
it('eof returns true after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect($limited->eof())->toBeTrue();
});
it('throws RuntimeException on read after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->read(1))->toThrow(RuntimeException::class);
});
it('throws RuntimeException on tell after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->tell())->toThrow(RuntimeException::class);
});
it('throws RuntimeException on seek after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->seek(0))->toThrow(RuntimeException::class);
});
it('throws RuntimeException on getContents after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->getContents())->toThrow(RuntimeException::class);
});
it('__toString returns empty string after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect((string) $limited)->toBe('');
});
// --- Write rejection ---
it('isWritable returns false', function () {
expect((new LimitedResource(new Buffer('hello'), 0, 5))->isWritable())->toBeFalse();
});
it('write throws IOException', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect(fn() => $limited->write('x'))->toThrow(IOException::class);
});
// --- Underlying position independence ---
it('reads correctly regardless of where the underlying stream cursor is', function () {
$buffer = new Buffer('Hello, World!');
$limited = new LimitedResource($buffer, 7, 5);
$buffer->seek(0);
expect($limited->read(5))->toBe('World');
});
it('two LimitedResource windows on the same stream read independently', function () {
$buffer = new Buffer('Hello, World!');
$hello = new LimitedResource($buffer, 0, 5);
$world = new LimitedResource($buffer, 7, 5);
expect($hello->read(3))->toBe('Hel')
->and($world->read(3))->toBe('Wor')
->and($hello->read(2))->toBe('lo')
->and($world->read(2))->toBe('ld');
});
// --- BinaryReader integration ---
it('integrates with BinaryReader for binary reads from a scoped section', function () {
$value = 0xDEADBEEF;
$data = str_repeat("\x00", 4) . pack('V', $value) . str_repeat("\x00", 4);
$buffer = new Buffer($data);
$limited = new LimitedResource($buffer, 4, 4);
$reader = BinaryReader::stream($limited);
expect($reader->length())->toBe(4)
->and($reader->readUInt32LE())->toBe($value);
});
it('BinaryReader seek works within the window', function () {
$value = 0x0000CAFE;
$data = str_repeat("\x00", 8) . pack('V', $value);
$buffer = new Buffer($data);
$limited = new LimitedResource($buffer, 8, 4);
$reader = BinaryReader::stream($limited);
$first = $reader->readUInt32LE();
$reader->seek(0);
$second = $reader->readUInt32LE();
expect($first)->toBe($value)
->and($second)->toBe($value);
});
// --- getMetadata ---
it('getMetadata always returns null', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect($limited->getMetadata())->toBeNull()
->and($limited->getMetadata('seekable'))->toBeNull();
});