Stream detects read/write more accurately; fix issues after detach()

This commit is contained in:
PJ Dietz 2020-08-09 10:55:37 -04:00
parent fe780e6b92
commit a7b08ad8a3
2 changed files with 194 additions and 16 deletions

View File

@ -6,7 +6,10 @@ use Psr\Http\Message\StreamInterface;
class Stream implements StreamInterface class Stream implements StreamInterface
{ {
/** @var resource */ private const READABLE_MODES = ['r', 'r+', 'w+', 'a+', 'x+', 'c+'];
private const WRITABLE_MODES = ['r+', 'w', 'w+', 'a', 'a+', 'x', 'x+', 'c', 'c+'];
/** @var resource|null */
private $resource; private $resource;
/** /**
@ -46,18 +49,16 @@ class Stream implements StreamInterface
*/ */
public function __toString() public function __toString()
{ {
$string = "";
try { try {
if ($this->isSeekable()) { if ($this->isSeekable()) {
rewind($this->resource); $this->rewind();
} }
$string = $this->getContents(); return $this->getContents();
// @codeCoverageIgnoreStart
} catch (\Exception $e) { } catch (\Exception $e) {
// @codeCoverageIgnoreEnd // Silence exceptions in order to conform with PHP's string casting
// Silence exceptions in order to conform with PHP's string casting operations. // operations.
return '';
} }
return $string;
} }
/** /**
@ -67,6 +68,10 @@ class Stream implements StreamInterface
*/ */
public function close() public function close()
{ {
if ($this->resource === null) {
return;
}
$resource = $this->resource; $resource = $this->resource;
fclose($resource); fclose($resource);
$this->resource = null; $this->resource = null;
@ -93,6 +98,10 @@ class Stream implements StreamInterface
*/ */
public function getSize() public function getSize()
{ {
if ($this->resource === null) {
return null;
}
$statistics = fstat($this->resource); $statistics = fstat($this->resource);
if ($statistics && $statistics["size"]) { if ($statistics && $statistics["size"]) {
return $statistics["size"]; return $statistics["size"];
@ -108,6 +117,10 @@ class Stream implements StreamInterface
*/ */
public function tell() public function tell()
{ {
if ($this->resource === null) {
throw new \RuntimeException("Unable to retrieve current position of detached stream.");
}
$position = ftell($this->resource); $position = ftell($this->resource);
if ($position === false) { if ($position === false) {
throw new \RuntimeException("Unable to retrieve current position of file pointer."); throw new \RuntimeException("Unable to retrieve current position of file pointer.");
@ -122,6 +135,10 @@ class Stream implements StreamInterface
*/ */
public function eof() public function eof()
{ {
if ($this->resource === null) {
return true;
}
return feof($this->resource); return feof($this->resource);
} }
@ -132,6 +149,10 @@ class Stream implements StreamInterface
*/ */
public function isSeekable() public function isSeekable()
{ {
if ($this->resource === null) {
return false;
}
return $this->getMetadata("seekable") == 1; return $this->getMetadata("seekable") == 1;
} }
@ -149,6 +170,10 @@ class Stream implements StreamInterface
*/ */
public function seek($offset, $whence = SEEK_SET) public function seek($offset, $whence = SEEK_SET)
{ {
if ($this->resource === null) {
throw new \RuntimeException("Unable to seek detached stream.");
}
$result = -1; $result = -1;
if ($this->isSeekable()) { if ($this->isSeekable()) {
$result = fseek($this->resource, $offset, $whence); $result = fseek($this->resource, $offset, $whence);
@ -170,12 +195,16 @@ class Stream implements StreamInterface
*/ */
public function rewind() public function rewind()
{ {
if ($this->resource === null) {
throw new \RuntimeException("Unable to seek detached stream.");
}
$result = false; $result = false;
if ($this->isSeekable()) { if ($this->isSeekable()) {
$result = rewind($this->resource); $result = rewind($this->resource);
} }
if ($result === false) { if ($result === false) {
throw new \RuntimeException("Unable to seek to position."); throw new \RuntimeException("Unable to rewind.");
} }
} }
@ -186,8 +215,12 @@ class Stream implements StreamInterface
*/ */
public function isWritable() public function isWritable()
{ {
$mode = $this->getMetadata("mode"); if ($this->resource === null) {
return $mode[0] !== "r" || strpos($mode, "+") !== false; return false;
}
$mode = $this->getBasicMode();
return in_array($mode, self::WRITABLE_MODES);
} }
/** /**
@ -199,6 +232,10 @@ class Stream implements StreamInterface
*/ */
public function write($string) public function write($string)
{ {
if ($this->resource === null) {
throw new \RuntimeException("Unable to write to detached stream.");
}
$result = false; $result = false;
if ($this->isWritable()) { if ($this->isWritable()) {
$result = fwrite($this->resource, $string); $result = fwrite($this->resource, $string);
@ -216,8 +253,12 @@ class Stream implements StreamInterface
*/ */
public function isReadable() public function isReadable()
{ {
$mode = $this->getMetadata("mode"); if ($this->resource === null) {
return strpos($mode, "r") !== false || strpos($mode, "+") !== false; return false;
}
$mode = $this->getBasicMode();
return in_array($mode, self::READABLE_MODES);
} }
/** /**
@ -232,6 +273,10 @@ class Stream implements StreamInterface
*/ */
public function read($length) public function read($length)
{ {
if ($this->resource === null) {
throw new \RuntimeException("Unable to read to detached stream.");
}
$result = false; $result = false;
if ($this->isReadable()) { if ($this->isReadable()) {
$result = fread($this->resource, $length); $result = fread($this->resource, $length);
@ -251,6 +296,10 @@ class Stream implements StreamInterface
*/ */
public function getContents() public function getContents()
{ {
if ($this->resource === null) {
throw new \RuntimeException("Unable to read to detached stream.");
}
$result = false; $result = false;
if ($this->isReadable()) { if ($this->isReadable()) {
$result = stream_get_contents($this->resource); $result = stream_get_contents($this->resource);
@ -268,13 +317,17 @@ class Stream implements StreamInterface
* stream_get_meta_data() function. * stream_get_meta_data() function.
* *
* @link http://php.net/manual/en/function.stream-get-meta-data.php * @link http://php.net/manual/en/function.stream-get-meta-data.php
* @param string $key Specific metadata to retrieve. * @param string|null $key Specific metadata to retrieve.
* @return array|mixed|null Returns an associative array if no key is * @return array|mixed|null Returns an associative array if no key is
* provided. Returns a specific key value if a key is provided and the * provided. Returns a specific key value if a key is provided and the
* value is found, or null if the key is not found. * value is found, or null if the key is not found.
*/ */
public function getMetadata($key = null) public function getMetadata($key = null)
{ {
if ($this->resource === null) {
return null;
}
$metadata = stream_get_meta_data($this->resource); $metadata = stream_get_meta_data($this->resource);
if ($key === null) { if ($key === null) {
return $metadata; return $metadata;
@ -282,4 +335,14 @@ class Stream implements StreamInterface
return $metadata[$key]; return $metadata[$key];
} }
} }
/**
* @return string Mode for the resource reduced to only the characters
* r, w, a, x, c, and + needed to determine readable and writeable status.
*/
private function getBasicMode()
{
$mode = $this->getMetadata('mode') ?? '';
return preg_replace('/[^rwaxc+]/', '', $mode);
}
} }

View File

@ -13,14 +13,14 @@ class StreamTest extends TestCase
private $resourceDevNull; private $resourceDevNull;
private $content = "Hello, world!"; private $content = "Hello, world!";
public function setUp(): void protected function setUp(): void
{ {
$this->resource = fopen("php://memory", "w+"); $this->resource = fopen("php://memory", "w+");
$this->resourceDevNull = fopen("/dev/null", "r"); $this->resourceDevNull = fopen("/dev/null", "r");
fwrite($this->resource, $this->content); fwrite($this->resource, $this->content);
} }
public function tearDown(): void protected function tearDown(): void
{ {
if (is_resource($this->resource)) { if (is_resource($this->resource)) {
fclose($this->resource); fclose($this->resource);
@ -269,4 +269,119 @@ class StreamTest extends TestCase
["c+", true, true] ["c+", true, true]
]; ];
} }
// -------------------------------------------------------------------------
// After Detach
public function testAfterDetachToStringReturnsEmptyString(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertEquals('', (string) $stream);
}
public function testAfterDetachCloseDoesNothing(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$stream->close();
$this->assertTrue(true);
}
public function testAfterDetachDetachReturnsNull(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertNull($stream->detach());
}
public function testAfterDetachGetSizeReturnsNull(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertNull($stream->getSize());
}
public function testAfterDetachTellThrowsRuntimeException(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->expectException(RuntimeException::class);
$stream->tell();
}
public function testAfterDetachEofReturnsTrue(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertTrue($stream->eof());
}
public function testAfterDetachIsSeekableReturnsFalse(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertFalse($stream->isSeekable());
}
public function testAfterDetachSeekThrowsRuntimeException(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->expectException(RuntimeException::class);
$stream->seek(0);
}
public function testAfterDetachRewindThrowsRuntimeException(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->expectException(RuntimeException::class);
$stream->rewind();
}
public function testAfterDetachIsWritableReturnsFalse(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertFalse($stream->isWritable());
}
public function testAfterDetachWriteThrowsRuntimeException(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->expectException(RuntimeException::class);
$stream->write('bork');
}
public function testAfterDetachIsReadableReturnsFalse(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertFalse($stream->isReadable());
}
public function testAfterDetachReadThrowsRuntimeException(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->expectException(RuntimeException::class);
$stream->read(10);
}
public function testAfterDetachGetContentsThrowsRuntimeException(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->expectException(RuntimeException::class);
$stream->getContents();
}
public function testAfterDetachGetMetadataReturnsNull(): void
{
$stream = new Stream($this->resource);
$stream->detach();
$this->assertNull($stream->getMetadata());
}
} }