From a0dcdcd7a84b4a01f376a810991c7dc4f5078ebd Mon Sep 17 00:00:00 2001 From: Holger Woltersdorf Date: Tue, 29 Jan 2019 00:05:04 +0100 Subject: [PATCH] Add exception for response packages of type STDERR, #26 * Refers mainly to the error "Primary script unknown" resp. the response "File not found." * Adds new ProjectManagerException * Adds integration tests for network and unix domain socket --- src/Exceptions/ProcessManagerException.php | 8 +++ src/Socket.php | 69 +++++++++++++++++----- tests/Integration/NetworkSocketTest.php | 48 +++++++++++++-- tests/Integration/UnixDomainSocketTest.php | 45 +++++++++++++- 4 files changed, 148 insertions(+), 22 deletions(-) create mode 100644 src/Exceptions/ProcessManagerException.php diff --git a/src/Exceptions/ProcessManagerException.php b/src/Exceptions/ProcessManagerException.php new file mode 100644 index 0000000..a177f1d --- /dev/null +++ b/src/Exceptions/ProcessManagerException.php @@ -0,0 +1,8 @@ +connection->getConnectTimeout() / 1000 ); } - catch ( \Throwable $e ) + catch ( Throwable $e ) { throw new ConnectException( $e->getMessage(), $e->getCode(), $e ); } @@ -214,7 +235,7 @@ private function handleFailedResource( $errorNumber, $errorString ) : void if ( null !== $lastError ) { - $lastErrorException = new \ErrorException( + $lastErrorException = new ErrorException( $lastError['message'] ?? '[No message available]', 0, $lastError['type'] ?? E_ERROR, @@ -244,7 +265,7 @@ private function getRequestPackets( ProvidesRequestData $request ) : string # Keep alive bit always set to 1 $requestPackets = $this->packetEncoder->encodePacket( self::BEGIN_REQUEST, - \chr( 0 ) . \chr( self::RESPONDER ) . \chr( 1 ) . str_repeat( \chr( 0 ), 5 ), + chr( 0 ) . chr( self::RESPONDER ) . chr( 1 ) . str_repeat( chr( 0 ), 5 ), $this->id ); @@ -262,7 +283,15 @@ private function getRequestPackets( ProvidesRequestData $request ) : string $offset = 0; do { - $requestPackets .= $this->packetEncoder->encodePacket( self::STDIN, substr( $request->getContent(), $offset, self::REQ_MAX_CONTENT_SIZE ), $this->id ); + $requestPackets .= $this->packetEncoder->encodePacket( + self::STDIN, + substr( + $request->getContent(), + $offset, + self::REQ_MAX_CONTENT_SIZE + ), + $this->id + ); $offset += self::REQ_MAX_CONTENT_SIZE; } while ( $offset < $request->getContentLength() ); @@ -284,7 +313,7 @@ private function write( string $data ) : void $writeResult = fwrite( $this->resource, $data ); $flushResult = fflush( $this->resource ); - if ( $writeResult === false || $flushResult === false ) + if ( $writeResult === false || !$flushResult ) { $info = stream_get_meta_data( $this->resource ); @@ -300,8 +329,12 @@ private function write( string $data ) : void /** * @param int|null $timeoutMs * + * @throws ForbiddenException + * @throws ProcessManagerException + * @throws ReadFailedException + * @throws TimedoutException + * @throws WriteFailedException * @return ProvidesResponseData - * @throws \Throwable */ public function fetchResponse( ?int $timeoutMs = null ) : ProvidesResponseData { @@ -313,8 +346,11 @@ public function fetchResponse( ?int $timeoutMs = null ) : ProvidesResponseData // Reset timeout on socket for reading $this->setStreamTimeout( $timeoutMs ?? $this->connection->getReadWriteTimeout() ); + $errorContent = ''; $responseContent = ''; + $this->status = self::REQ_STATE_OK; + do { $packet = $this->readPacket(); @@ -322,8 +358,8 @@ public function fetchResponse( ?int $timeoutMs = null ) : ProvidesResponseData switch ( (int)$packet['type'] ) { case self::STDERR: - $this->status = self::REQ_STATE_ERR; - $responseContent .= $packet['content']; + $this->status = self::REQ_STATE_ERR; + $errorContent .= $packet['content']; $this->notifyPassThroughCallbacks( $packet['content'] ); break; @@ -335,7 +371,6 @@ public function fetchResponse( ?int $timeoutMs = null ) : ProvidesResponseData case self::END_REQUEST: if ( $packet['requestId'] === $this->id ) { - $this->status = self::REQ_STATE_OK; break 2; } break; @@ -346,7 +381,12 @@ public function fetchResponse( ?int $timeoutMs = null ) : ProvidesResponseData try { $this->handleNullPacket( $packet ); - $this->guardRequestCompleted( \ord( $packet['content']{4} ) ); + $this->guardRequestCompleted( ord( $packet['content']{4} ) ); + + if ( $this->status === self::REQ_STATE_ERR ) + { + throw new ProcessManagerException( 'An error occurred: ' . $errorContent ); + } $this->response = new Response( $this->id, @@ -356,7 +396,7 @@ public function fetchResponse( ?int $timeoutMs = null ) : ProvidesResponseData return $this->response; } - catch ( \Throwable $e ) + catch ( WriteFailedException | ReadFailedException $e ) { throw $e; } @@ -379,13 +419,14 @@ private function readPacket() : ?array while ( $length && ($buffer = fread( $this->resource, $length )) !== false ) { - $length -= \strlen( $buffer ); + $length -= strlen( $buffer ); $packet['content'] .= $buffer; } } if ( $packet['paddingLength'] ) { + /** @noinspection UnusedFunctionResultInspection */ fread( $this->resource, $packet['paddingLength'] ); } @@ -459,7 +500,7 @@ private function guardRequestCompleted( int $flag ) : void private function disconnect() : void { - if ( \is_resource( $this->resource ) ) + if ( is_resource( $this->resource ) ) { fclose( $this->resource ); } @@ -478,7 +519,7 @@ public function notifyResponseCallbacks( ProvidesResponseData $response ) : void } } - public function notifyFailureCallbacks( \Throwable $throwable ) : void + public function notifyFailureCallbacks( Throwable $throwable ) : void { foreach ( $this->failureCallbacks as $failureCallback ) { diff --git a/tests/Integration/NetworkSocketTest.php b/tests/Integration/NetworkSocketTest.php index 89a2c4c..565697f 100644 --- a/tests/Integration/NetworkSocketTest.php +++ b/tests/Integration/NetworkSocketTest.php @@ -24,7 +24,10 @@ namespace hollodotme\FastCGI\Tests\Integration; use hollodotme\FastCGI\Client; +use hollodotme\FastCGI\Exceptions\ConnectException; +use hollodotme\FastCGI\Exceptions\ProcessManagerException; use hollodotme\FastCGI\Interfaces\ProvidesResponseData; +use hollodotme\FastCGI\Requests\GetRequest; use hollodotme\FastCGI\Requests\PostRequest; use hollodotme\FastCGI\SocketConnections\Defaults; use hollodotme\FastCGI\SocketConnections\NetworkSocket; @@ -255,6 +258,7 @@ public function testReadingSyncResponseCanTimeOut() : void $content = http_build_query( ['sleep' => 2, 'test-key' => 'unit'] ); $request = new PostRequest( __DIR__ . '/Workers/sleepWorker.php', $content ); + /** @noinspection UnusedFunctionResultInspection */ $client->sendRequest( $request ); } @@ -290,8 +294,6 @@ function ( ProvidesResponseData $response ) use ( $unitTest ) } /** - * @throws \Exception - * @throws \PHPUnit\Framework\Exception * @throws \hollodotme\FastCGI\Exceptions\ConnectException * @throws \hollodotme\FastCGI\Exceptions\TimedoutException * @throws \hollodotme\FastCGI\Exceptions\WriteFailedException @@ -365,9 +367,7 @@ public function testReadResponsesSkipsUnknownRequestIds() : void sleep( 1 ); - $responses = $client->readResponses( null, ...$requestIds ); - - foreach ( $responses as $response ) + foreach ( $client->readResponses( null, ...$requestIds ) as $response ) { echo $response->getBody(); } @@ -482,4 +482,42 @@ public function contentLengthProvider() : array ], ]; } + + /** + * @param string $scriptFilename + * + * @throws ConnectException + * @throws \PHPUnit\Framework\AssertionFailedError + * @throws \Throwable + * @throws \hollodotme\FastCGI\Exceptions\TimedoutException + * @throws \hollodotme\FastCGI\Exceptions\WriteFailedException + * @dataProvider invalidScriptFileNamesProvider + */ + public function testRequestingAnUnknownScriptPathThrowsException( string $scriptFilename ) : void + { + $connection = new NetworkSocket( '127.0.0.1', 9000 ); + $client = new Client( $connection ); + $request = new GetRequest( $scriptFilename, '' ); + + $this->expectException( ProcessManagerException::class ); + $this->expectExceptionMessage( 'An error occurred: Primary script unknown' ); + + /** @noinspection UnusedFunctionResultInspection */ + $client->sendRequest( $request ); + + $this->fail( 'Expecteed exception to be thrown that the primary script is unknown.' ); + } + + public function invalidScriptFileNamesProvider() : array + { + return [ + [ + 'scriptFilename' => '/unknown/script.php', + ], + [ + # Existing script filenames containing path traversals do not work either + 'scriptFilename' => __DIR__ . '/../Integration/Workers/worker.php', + ], + ]; + } } diff --git a/tests/Integration/UnixDomainSocketTest.php b/tests/Integration/UnixDomainSocketTest.php index f98ab24..1e40bc3 100644 --- a/tests/Integration/UnixDomainSocketTest.php +++ b/tests/Integration/UnixDomainSocketTest.php @@ -24,7 +24,9 @@ namespace hollodotme\FastCGI\Tests\Integration; use hollodotme\FastCGI\Client; +use hollodotme\FastCGI\Exceptions\ProcessManagerException; use hollodotme\FastCGI\Interfaces\ProvidesResponseData; +use hollodotme\FastCGI\Requests\GetRequest; use hollodotme\FastCGI\Requests\PostRequest; use hollodotme\FastCGI\SocketConnections\Defaults; use hollodotme\FastCGI\SocketConnections\UnixDomainSocket; @@ -255,6 +257,7 @@ public function testReadingSyncResponseCanTimeOut() : void $content = http_build_query( ['sleep' => 2, 'test-key' => 'unit'] ); $request = new PostRequest( __DIR__ . '/Workers/sleepWorker.php', $content ); + /** @noinspection UnusedFunctionResultInspection */ $client->sendRequest( $request ); } @@ -365,9 +368,7 @@ public function testReadResponsesSkipsUnknownRequestIds() : void sleep( 1 ); - $responses = $client->readResponses( null, ...$requestIds ); - - foreach ( $responses as $response ) + foreach ( $client->readResponses( null, ...$requestIds ) as $response ) { echo $response->getBody(); } @@ -479,4 +480,42 @@ public function contentLengthProvider() : array ], ]; } + + /** + * @param string $scriptFilename + * + * @throws \PHPUnit\Framework\AssertionFailedError + * @throws \Throwable + * @throws \hollodotme\FastCGI\Exceptions\ConnectException + * @throws \hollodotme\FastCGI\Exceptions\TimedoutException + * @throws \hollodotme\FastCGI\Exceptions\WriteFailedException + * @dataProvider invalidScriptFileNamesProvider + */ + public function testRequestingAnUnknownScriptPathThrowsException( string $scriptFilename ) : void + { + $connection = new UnixDomainSocket( '/var/run/php-uds.sock' ); + $client = new Client( $connection ); + $request = new GetRequest( $scriptFilename, '' ); + + $this->expectException( ProcessManagerException::class ); + $this->expectExceptionMessage( 'An error occurred: Primary script unknown' ); + + /** @noinspection UnusedFunctionResultInspection */ + $client->sendRequest( $request ); + + $this->fail( 'Expecteed exception to be thrown that the primary script is unknown.' ); + } + + public function invalidScriptFileNamesProvider() : array + { + return [ + [ + 'scriptFilename' => '/unknown/script.php', + ], + [ + # Existing script filenames containing path traversals do not work either + 'scriptFilename' => __DIR__ . '/../Integration/Workers/worker.php', + ], + ]; + } }