diff --git a/.travis.yml b/.travis.yml index 09125d8..f8f7b7c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,26 @@ language: php +services: docker + php: - - 5.4 - - 5.5 - 5.6 - 7.0 - 7.1 - 7.2 + - 7.3 + - 7.4 + - nightly + +env: + - ABTEST=client + - ABTEST=server + +matrix: + allow_failures: + - php: nightly before_install: - - export PATH=$HOME/.local/bin:$PATH - - pip install --user autobahntestsuite - - pip list --user autobahntestsuite + - docker pull crossbario/autobahn-testsuite before_script: - composer install diff --git a/README.md b/README.md index 0f17c14..fba5093 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RFC6455 - The WebSocket Protocol [![Build Status](https://travis-ci.org/ratchetphp/RFC6455.svg?branch=master)](https://travis-ci.org/ratchetphp/RFC6455) -![Autobahn Testsuite](https://img.shields.io/badge/Autobahn-passing-brightgreen.svg) +[![Autobahn Testsuite](https://img.shields.io/badge/Autobahn-passing-brightgreen.svg)](http://socketo.me/reports/rfc-server/index.html) This library a protocol handler for the RFC6455 specification. It contains components for both server and client side handshake and messaging protocol negotation. diff --git a/composer.json b/composer.json index 27935d8..07a043c 100644 --- a/composer.json +++ b/composer.json @@ -5,15 +5,20 @@ "keywords": ["WebSockets", "websocket", "RFC6455"], "homepage": "http://socketo.me", "license": "MIT", - "authors": [{ - "name": "Chris Boden" - , "email": "cboden@gmail.com" - , "role": "Developer" - }], + "authors": [ + { + "name": "Chris Boden" + , "email": "cboden@gmail.com" + , "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], "support": { - "forum": "https://groups.google.com/forum/#!forum/ratchet-php" - , "issues": "https://github.com/ratchetphp/RFC6455/issues" - , "irc": "irc://irc.freenode.org/reactphp" + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "chat": "https://gitter.im/reactphp/reactphp" }, "autoload": { "psr-4": { @@ -25,9 +30,18 @@ "psr/http-factory-implementation": "^1.0" }, "require-dev": { - "react/http": "^0.4.1", - "react/socket-client": "^0.4.3", - "phpunit/phpunit": "4.8.*", - "guzzlehttp/psr7": "^2.0-dev" + "guzzlehttp/psr7": "^2.0-dev", + "phpunit/phpunit": "5.7.*", + "react/socket": "^1.3" + }, + "scripts": { + "abtest-client": "ABTEST=client && sh tests/ab/run_ab_tests.sh", + "abtest-server": "ABTEST=server && sh tests/ab/run_ab_tests.sh", + "phpunit": "phpunit --colors=always", + "test": [ + "@abtest-client", + "@abtest-server", + "@phpunit" + ] } } diff --git a/src/Handshake/ClientNegotiator.php b/src/Handshake/ClientNegotiator.php index a12205a..ef98e06 100644 --- a/src/Handshake/ClientNegotiator.php +++ b/src/Handshake/ClientNegotiator.php @@ -21,7 +21,10 @@ class ClientNegotiator { */ private $requestFactory; - function __construct(RequestFactoryInterface $requestFactory) { + function __construct( + RequestFactoryInterface $requestFactory, + PermessageDeflateOptions $perMessageDeflateOptions = null + ) { $this->verifier = new ResponseVerifier; $this->requestFactory = $requestFactory; @@ -31,6 +34,24 @@ class ClientNegotiator { ->withHeader('Upgrade' , 'websocket') ->withHeader('Sec-WebSocket-Version', $this->getVersion()) ->withHeader('User-Agent' , 'Ratchet'); + + if ($perMessageDeflateOptions === null) { + $perMessageDeflateOptions = PermessageDeflateOptions::createDisabled(); + } + + // https://bugs.php.net/bug.php?id=73373 + // https://bugs.php.net/bug.php?id=74240 - need >=7.1.4 or >=7.0.18 + if ($perMessageDeflateOptions->isEnabled() && + !PermessageDeflateOptions::permessageDeflateSupported()) { + trigger_error('permessage-deflate is being disabled because it is not support by your PHP version.', E_USER_NOTICE); + $perMessageDeflateOptions = PermessageDeflateOptions::createDisabled(); + } + if ($perMessageDeflateOptions->isEnabled() && !function_exists('deflate_add')) { + trigger_error('permessage-deflate is being disabled because you do not have the zlib extension.', E_USER_NOTICE); + $perMessageDeflateOptions = PermessageDeflateOptions::createDisabled(); + } + + $this->defaultHeader = $perMessageDeflateOptions->addHeaderToRequest($this->defaultHeader); } public function generateRequest(UriInterface $uri) { diff --git a/src/Handshake/InvalidPermessageDeflateOptionsException.php b/src/Handshake/InvalidPermessageDeflateOptionsException.php new file mode 100644 index 0000000..191e7a5 --- /dev/null +++ b/src/Handshake/InvalidPermessageDeflateOptionsException.php @@ -0,0 +1,7 @@ +deflateEnabled = true; + $new->client_max_window_bits = self::MAX_WINDOW_BITS; + $new->client_no_context_takeover = false; + $new->server_max_window_bits = self::MAX_WINDOW_BITS; + $new->server_no_context_takeover = false; + + return $new; + } + + public static function createDisabled() { + return new static(); + } + + public function withClientNoContextTakeover() { + $new = clone $this; + $new->client_no_context_takeover = true; + } + + public function withoutClientNoContextTakeover() { + $new = clone $this; + $new->client_no_context_takeover = false; + } + + public function withServerNoContextTakeover() { + $new = clone $this; + $new->server_no_context_takeover = true; + } + + public function withoutServerNoContextTakeover() { + $new = clone $this; + $new->server_no_context_takeover = false; + } + + public function withServerMaxWindowBits($bits = self::MAX_WINDOW_BITS) { + if (!in_array($bits, self::$VALID_BITS)) { + throw new \Exception('server_max_window_bits must have a value between 8 and 15.'); + } + $new = clone $this; + $new->server_max_window_bits = $bits; + } + + public function withClientMaxWindowBits($bits = self::MAX_WINDOW_BITS) { + if (!in_array($bits, self::$VALID_BITS)) { + throw new \Exception('client_max_window_bits must have a value between 8 and 15.'); + } + $new = clone $this; + $new->client_max_window_bits = $bits; + } + + /** + * https://tools.ietf.org/html/rfc6455#section-9.1 + * https://tools.ietf.org/html/rfc7692#section-7 + * + * @param MessageInterface $requestOrResponse + * @return PermessageDeflateOptions[] + * @throws \Exception + */ + public static function fromRequestOrResponse(MessageInterface $requestOrResponse) { + $optionSets = []; + + $extHeader = preg_replace('/\s+/', '', join(', ', $requestOrResponse->getHeader('Sec-Websocket-Extensions'))); + + $configurationRequests = explode(',', $extHeader); + foreach ($configurationRequests as $configurationRequest) { + $parts = explode(';', $configurationRequest); + if (count($parts) == 0) { + continue; + } + + if ($parts[0] !== 'permessage-deflate') { + continue; + } + + array_shift($parts); + $options = new static(); + $options->deflateEnabled = true; + foreach ($parts as $part) { + $kv = explode('=', $part); + $key = $kv[0]; + $value = count($kv) > 1 ? $kv[1] : null; + + switch ($key) { + case "server_no_context_takeover": + case "client_no_context_takeover": + if ($value !== null) { + throw new InvalidPermessageDeflateOptionsException($key . ' must not have a value.'); + } + $value = true; + break; + case "server_max_window_bits": + if (!in_array($value, self::$VALID_BITS)) { + throw new InvalidPermessageDeflateOptionsException($key . ' must have a value between 8 and 15.'); + } + break; + case "client_max_window_bits": + if ($value === null) { + $value = '15'; + } + if (!in_array($value, self::$VALID_BITS)) { + throw new InvalidPermessageDeflateOptionsException($key . ' must have no value or a value between 8 and 15.'); + } + break; + default: + throw new InvalidPermessageDeflateOptionsException('Option "' . $key . '"is not valid for permessage deflate'); + } + + if ($options->$key !== null) { + throw new InvalidPermessageDeflateOptionsException($key . ' specified more than once. Connection must be declined.'); + } + + $options->$key = $value; + } + + if ($options->getClientMaxWindowBits() === null) { + $options->client_max_window_bits = 15; + } + + if ($options->getServerMaxWindowBits() === null) { + $options->server_max_window_bits = 15; + } + + $optionSets[] = $options; + } + + // always put a disabled on the end + $optionSets[] = new static(); + + return $optionSets; + } + + /** + * @return mixed + */ + public function getServerNoContextTakeover() + { + return $this->server_no_context_takeover; + } + + /** + * @return mixed + */ + public function getClientNoContextTakeover() + { + return $this->client_no_context_takeover; + } + + /** + * @return mixed + */ + public function getServerMaxWindowBits() + { + return $this->server_max_window_bits; + } + + /** + * @return mixed + */ + public function getClientMaxWindowBits() + { + return $this->client_max_window_bits; + } + + /** + * @return bool + */ + public function isEnabled() + { + return $this->deflateEnabled; + } + + /** + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function addHeaderToResponse(ResponseInterface $response) + { + if (!$this->deflateEnabled) { + return $response; + } + + $header = 'permessage-deflate'; + if ($this->client_max_window_bits != 15) { + $header .= '; client_max_window_bits='. $this->client_max_window_bits; + } + if ($this->client_no_context_takeover) { + $header .= '; client_no_context_takeover'; + } + if ($this->server_max_window_bits != 15) { + $header .= '; server_max_window_bits=' . $this->server_max_window_bits; + } + if ($this->server_no_context_takeover) { + $header .= '; server_no_context_takeover'; + } + + return $response->withAddedHeader('Sec-Websocket-Extensions', $header); + } + + public function addHeaderToRequest(RequestInterface $request) { + if (!$this->deflateEnabled) { + return $request; + } + + $header = 'permessage-deflate'; + if ($this->server_no_context_takeover) { + $header .= '; server_no_context_takeover'; + } + if ($this->client_no_context_takeover) { + $header .= '; client_no_context_takeover'; + } + if ($this->server_max_window_bits != 15) { + $header .= '; server_max_window_bits=' . $this->server_max_window_bits; + } + $header .= '; client_max_window_bits'; + if ($this->client_max_window_bits != 15) { + $header .= '='. $this->client_max_window_bits; + } + + return $request->withAddedHeader('Sec-Websocket-Extensions', $header); + } + + public static function permessageDeflateSupported($version = PHP_VERSION) { + if (!function_exists('deflate_init')) { + return false; + } + if (version_compare($version, '7.1.3', '>')) { + return true; + } + if (version_compare($version, '7.0.18', '>=') + && version_compare($version, '7.1.0', '<')) { + return true; + } + + return false; + } +} diff --git a/src/Handshake/RequestVerifier.php b/src/Handshake/RequestVerifier.php index 1ace489..dbce9a9 100644 --- a/src/Handshake/RequestVerifier.php +++ b/src/Handshake/RequestVerifier.php @@ -137,4 +137,27 @@ class RequestVerifier { */ public function verifyExtensions($val) { } + + public function getPermessageDeflateOptions(array $requestHeader, array $responseHeader) { + $deflate = true; + if (!isset($requestHeader['Sec-WebSocket-Extensions']) || count(array_filter($requestHeader['Sec-WebSocket-Extensions'], function ($val) { + return 'permessage-deflate' === substr($val, 0, strlen('permessage-deflate')); + })) === 0) { + $deflate = false; + } + + if (!isset($responseHeader['Sec-WebSocket-Extensions']) || count(array_filter($responseHeader['Sec-WebSocket-Extensions'], function ($val) { + return 'permessage-deflate' === substr($val, 0, strlen('permessage-deflate')); + })) === 0) { + $deflate = false; + } + + return [ + 'deflate' => $deflate, + 'no_context_takeover' => false, + 'max_window_bits' => null, + 'request_no_context_takeover' => false, + 'request_max_window_bits' => null + ]; + } } diff --git a/src/Handshake/ResponseVerifier.php b/src/Handshake/ResponseVerifier.php index de03f53..38904e8 100644 --- a/src/Handshake/ResponseVerifier.php +++ b/src/Handshake/ResponseVerifier.php @@ -18,8 +18,12 @@ class ResponseVerifier { $request->getHeader('Sec-WebSocket-Protocol') , $response->getHeader('Sec-WebSocket-Protocol') ); + $passes += (int)$this->verifyExtensions( + $request->getHeader('Sec-WebSocket-Extensions') + , $response->getHeader('Sec-WebSocket-Extensions') + ); - return (5 === $passes); + return (6 === $passes); } public function verifyStatus($status) { @@ -49,4 +53,12 @@ class ResponseVerifier { public function verifySubProtocol(array $requestHeader, array $responseHeader) { return 0 === count($responseHeader) || count(array_intersect($responseHeader, $requestHeader)) > 0; } -} \ No newline at end of file + + public function verifyExtensions(array $requestHeader, array $responseHeader) { + if (in_array('permessage-deflate', $responseHeader)) { + return strpos(implode(',', $requestHeader), 'permessage-deflate') !== false ? 1 : 0; + } + + return 1; + } +} diff --git a/src/Handshake/ServerNegotiator.php b/src/Handshake/ServerNegotiator.php index 742f10d..e083720 100644 --- a/src/Handshake/ServerNegotiator.php +++ b/src/Handshake/ServerNegotiator.php @@ -22,9 +22,27 @@ class ServerNegotiator implements NegotiatorInterface { private $_strictSubProtocols = false; - public function __construct(RequestVerifier $requestVerifier, ResponseFactoryInterface $responseFactory) { + private $enablePerMessageDeflate = false; + + public function __construct( + RequestVerifier $requestVerifier, + ResponseFactoryInterface $responseFactory, + $enablePerMessageDeflate = false + ) { $this->verifier = $requestVerifier; $this->responseFactory = $responseFactory; + + // https://bugs.php.net/bug.php?id=73373 + // https://bugs.php.net/bug.php?id=74240 - need >=7.1.4 or >=7.0.18 + $supported = PermessageDeflateOptions::permessageDeflateSupported(); + if ($enablePerMessageDeflate && !$supported) { + throw new \Exception('permessage-deflate is not supported by your PHP version (need >=7.1.4 or >=7.0.18).'); + } + if ($enablePerMessageDeflate && !function_exists('deflate_add')) { + throw new \Exception('permessage-deflate is not supported because you do not have the zlib extension.'); + } + + $this->enablePerMessageDeflate = $enablePerMessageDeflate; } /** @@ -104,12 +122,25 @@ class ServerNegotiator implements NegotiatorInterface { $response = $response->withHeader('Sec-WebSocket-Protocol', $match); } } - return $response + + $response = $response ->withStatus(101) ->withHeader('Upgrade' , 'websocket') ->withHeader('Connection' , 'Upgrade') ->withHeader('Sec-WebSocket-Accept', $this->sign((string)$request->getHeader('Sec-WebSocket-Key')[0])) ->withHeader('X-Powered-By' , 'Ratchet'); + + try { + $perMessageDeflateRequest = PermessageDeflateOptions::fromRequestOrResponse($request)[0]; + } catch (InvalidPermessageDeflateOptionsException $e) { + return new Response(400, [], null, '1.1', $e->getMessage()); + } + + if ($this->enablePerMessageDeflate && $perMessageDeflateRequest->isEnabled()) { + $response = $perMessageDeflateRequest->addHeaderToResponse($response); + } + + return $response; } /** diff --git a/src/Messaging/Frame.php b/src/Messaging/Frame.php index 360b70f..24b491e 100644 --- a/src/Messaging/Frame.php +++ b/src/Messaging/Frame.php @@ -149,6 +149,23 @@ class Frame implements FrameInterface { return 128 === ($this->firstByte & 128); } + public function setRsv1($value = true) { + if (strlen($this->data) == 0) { + throw new \UnderflowException("Cannot set Rsv1 because there is no data."); + } + + $this->firstByte = + ($this->isFinal() ? 128 : 0) + + $this->getOpcode() + + ($value ? 64 : 0) + + ($this->getRsv2() ? 32 : 0) + + ($this->getRsv3() ? 16 : 0) + ; + + $this->data[0] = chr($this->firstByte); + return $this; + } + /** * @return boolean * @throws \UnderflowException diff --git a/src/Messaging/Message.php b/src/Messaging/Message.php index 4f3b014..07c1fba 100644 --- a/src/Messaging/Message.php +++ b/src/Messaging/Message.php @@ -7,8 +7,14 @@ class Message implements \IteratorAggregate, MessageInterface { */ private $_frames; + /** + * @var int + */ + private $len; + public function __construct() { $this->_frames = new \SplDoublyLinkedList; + $this->len = 0; } public function getIterator() { @@ -39,6 +45,7 @@ class Message implements \IteratorAggregate, MessageInterface { * {@inheritdoc} */ public function addFrame(FrameInterface $fragment) { + $this->len += $fragment->getPayloadLength(); $this->_frames->push($fragment); return $this; @@ -59,17 +66,7 @@ class Message implements \IteratorAggregate, MessageInterface { * {@inheritdoc} */ public function getPayloadLength() { - $len = 0; - - foreach ($this->_frames as $frame) { - try { - $len += $frame->getPayloadLength(); - } catch (\UnderflowException $e) { - // Not an error, want the current amount buffered - } - } - - return $len; + return $this->len; } /** @@ -120,4 +117,16 @@ class Message implements \IteratorAggregate, MessageInterface { return Frame::OP_BINARY === $this->_frames->bottom()->getOpcode(); } + + /** + * @return boolean + */ + public function getRsv1() { + if ($this->_frames->isEmpty()) { + return false; + //throw new \UnderflowException('Not enough data has been received to determine if message is binary'); + } + + return $this->_frames->bottom()->getRsv1(); + } } diff --git a/src/Messaging/MessageBuffer.php b/src/Messaging/MessageBuffer.php index 22f247c..d761786 100644 --- a/src/Messaging/MessageBuffer.php +++ b/src/Messaging/MessageBuffer.php @@ -1,6 +1,8 @@ closeFrameChecker = $frameChecker; $this->checkForMask = (bool)$expectMask; - $this->exceptionFactory ?: $this->exceptionFactory = function($msg) { + $this->exceptionFactory ?: $exceptionFactory = function($msg) { return new \UnderflowException($msg); }; $this->onMessage = $onMessage; $this->onControl = $onControl ?: function() {}; + + $this->sender = $sender; + + $this->permessageDeflateOptions = $permessageDeflateOptions ?: PermessageDeflateOptions::createDisabled(); + + $this->deflateEnabled = $this->permessageDeflateOptions->isEnabled(); + + if ($this->deflateEnabled && !is_callable($this->sender)) { + throw new \InvalidArgumentException('sender must be set when deflate is enabled'); + } + + $this->compressedMessage = false; + + $this->leftovers = ''; + + $memory_limit_bytes = static::getMemoryLimit(); + + if ($maxMessagePayloadSize === null) { + $maxMessagePayloadSize = $memory_limit_bytes / 4; + } + if ($maxFramePayloadSize === null) { + $maxFramePayloadSize = $memory_limit_bytes / 4; + } + + if (!is_int($maxFramePayloadSize) || $maxFramePayloadSize > 0x7FFFFFFFFFFFFFFF || $maxFramePayloadSize < 0) { // this should be interesting on non-64 bit systems + throw new \InvalidArgumentException($maxFramePayloadSize . ' is not a valid maxFramePayloadSize'); + } + $this->maxFramePayloadSize = $maxFramePayloadSize; + + if (!is_int($maxMessagePayloadSize) || $maxMessagePayloadSize > 0x7FFFFFFFFFFFFFFF || $maxMessagePayloadSize < 0) { + throw new \InvalidArgumentException($maxMessagePayloadSize . 'is not a valid maxMessagePayloadSize'); + } + $this->maxMessagePayloadSize = $maxMessagePayloadSize; } public function onData($data) { - while (strlen($data) > 0) { - $data = $this->processData($data); + $data = $this->leftovers . $data; + $dataLen = strlen($data); + + if ($dataLen < 2) { + $this->leftovers = $data; + + return; } + + $frameStart = 0; + while ($frameStart + 2 <= $dataLen) { + $headerSize = 2; + $payload_length = unpack('C', $data[$frameStart + 1] & "\x7f")[1]; + $isMasked = ($data[$frameStart + 1] & "\x80") === "\x80"; + $headerSize += $isMasked ? 4 : 0; + if ($payload_length > 125 && ($dataLen - $frameStart < $headerSize + 125)) { + // no point of checking - this frame is going to be bigger than the buffer is right now + break; + } + if ($payload_length > 125) { + $payloadLenBytes = $payload_length === 126 ? 2 : 8; + $headerSize += $payloadLenBytes; + $bytesToUpack = substr($data, $frameStart + 2, $payloadLenBytes); + $payload_length = $payload_length === 126 + ? unpack('n', $bytesToUpack)[1] + : unpack('J', $bytesToUpack)[1]; + } + + $closeFrame = null; + + if ($payload_length < 0) { + // this can happen when unpacking in php + $closeFrame = $this->newCloseFrame(Frame::CLOSE_PROTOCOL, 'Invalid frame length'); + } + + if (!$closeFrame && $this->maxFramePayloadSize > 1 && $payload_length > $this->maxFramePayloadSize) { + $closeFrame = $this->newCloseFrame(Frame::CLOSE_TOO_BIG, 'Maximum frame size exceeded'); + } + + if (!$closeFrame && $this->maxMessagePayloadSize > 0 + && $payload_length + ($this->messageBuffer ? $this->messageBuffer->getPayloadLength() : 0) > $this->maxMessagePayloadSize) { + $closeFrame = $this->newCloseFrame(Frame::CLOSE_TOO_BIG, 'Maximum message size exceeded'); + } + + if ($closeFrame !== null) { + $onControl = $this->onControl; + $onControl($closeFrame); + $this->leftovers = ''; + + return; + } + + $isCoalesced = $dataLen - $frameStart >= $payload_length + $headerSize; + if (!$isCoalesced) { + break; + } + $this->processData(substr($data, $frameStart, $payload_length + $headerSize)); + $frameStart = $frameStart + $payload_length + $headerSize; + } + + $this->leftovers = substr($data, $frameStart); } /** @@ -70,27 +207,30 @@ class MessageBuffer { $this->frameBuffer ?: $this->frameBuffer = $this->newFrame(); $this->frameBuffer->addBuffer($data); - if (!$this->frameBuffer->isCoalesced()) { - return ''; - } $onMessage = $this->onMessage; $onControl = $this->onControl; $this->frameBuffer = $this->frameCheck($this->frameBuffer); - $overflow = $this->frameBuffer->extractOverflow(); $this->frameBuffer->unMaskPayload(); $opcode = $this->frameBuffer->getOpcode(); if ($opcode > 2) { - $onControl($this->frameBuffer); + $onControl($this->frameBuffer, $this); if (Frame::OP_CLOSE === $opcode) { return ''; } } else { + if ($this->messageBuffer->count() === 0 && $this->frameBuffer->getRsv1()) { + $this->compressedMessage = true; + } + if ($this->compressedMessage) { + $this->frameBuffer = $this->inflateFrame($this->frameBuffer); + } + $this->messageBuffer->addFrame($this->frameBuffer); } @@ -103,13 +243,18 @@ class MessageBuffer { $this->messageBuffer = null; if (true !== $msgCheck) { - $onControl($this->newCloseFrame($msgCheck, 'Ratchet detected an invalid UTF-8 payload')); + $onControl($this->newCloseFrame($msgCheck, 'Ratchet detected an invalid UTF-8 payload'), $this); } else { - $onMessage($msgBuffer); + $onMessage($msgBuffer, $this); + } + + $this->messageBuffer = null; + $this->compressedMessage = false; + + if ($this->permessageDeflateOptions->getServerNoContextTakeover()) { + $this->inflator = null; } } - - return $overflow; } /** @@ -118,7 +263,7 @@ class MessageBuffer { * @return \Ratchet\RFC6455\Messaging\FrameInterface|FrameInterface */ public function frameCheck(FrameInterface $frame) { - if (false !== $frame->getRsv1() || + if ((false !== $frame->getRsv1() && !$this->deflateEnabled) || false !== $frame->getRsv2() || false !== $frame->getRsv3() ) { @@ -230,4 +375,176 @@ class MessageBuffer { public function newCloseFrame($code, $reason = '') { return $this->newFrame(pack('n', $code) . $reason, true, Frame::OP_CLOSE); } + + public function sendFrame(Frame $frame) { + if ($this->sender === null) { + throw new \Exception('To send frames using the MessageBuffer, sender must be set.'); + } + + if ($this->deflateEnabled && + ($frame->getOpcode() === Frame::OP_TEXT || $frame->getOpcode() === Frame::OP_BINARY)) { + $frame = $this->deflateFrame($frame); + } + + if (!$this->checkForMask) { + $frame->maskPayload(); + } + + $sender = $this->sender; + $sender($frame->getContents()); + } + + public function sendMessage($messagePayload, $final = true, $isBinary = false) { + $opCode = $isBinary ? Frame::OP_BINARY : Frame::OP_TEXT; + if ($this->streamingMessageOpCode === -1) { + $this->streamingMessageOpCode = $opCode; + } + + if ($this->streamingMessageOpCode !== $opCode) { + throw new \Exception('Binary and text message parts cannot be streamed together.'); + } + + $frame = $this->newFrame($messagePayload, $final, $opCode); + + $this->sendFrame($frame); + + if ($final) { + // reset deflator if client doesn't remember contexts + if ($this->getDeflateNoContextTakeover()) { + $this->deflator = null; + } + $this->streamingMessageOpCode = -1; + } + } + + private $inflator; + + private function getDeflateNoContextTakeover() { + return $this->checkForMask ? + $this->permessageDeflateOptions->getServerNoContextTakeover() : + $this->permessageDeflateOptions->getClientNoContextTakeover(); + } + + private function getDeflateWindowBits() { + return $this->checkForMask ? $this->permessageDeflateOptions->getServerMaxWindowBits() : $this->permessageDeflateOptions->getClientMaxWindowBits(); + } + + private function getInflateNoContextTakeover() { + return $this->checkForMask ? + $this->permessageDeflateOptions->getClientNoContextTakeover() : + $this->permessageDeflateOptions->getServerNoContextTakeover(); + } + + private function getInflateWindowBits() { + return $this->checkForMask ? $this->permessageDeflateOptions->getClientMaxWindowBits() : $this->permessageDeflateOptions->getServerMaxWindowBits(); + } + + private function inflateFrame(Frame $frame) { + if ($this->inflator === null) { + $this->inflator = inflate_init( + ZLIB_ENCODING_RAW, + [ + 'level' => -1, + 'memory' => 8, + 'window' => $this->getInflateWindowBits(), + 'strategy' => ZLIB_DEFAULT_STRATEGY + ] + ); + } + + $terminator = ''; + if ($frame->isFinal()) { + $terminator = "\x00\x00\xff\xff"; + } + + gc_collect_cycles(); // memory runs away if we don't collect ?? + + return new Frame( + inflate_add($this->inflator, $frame->getPayload() . $terminator), + $frame->isFinal(), + $frame->getOpcode() + ); + } + + private $deflator; + + private function deflateFrame(Frame $frame) + { + if ($frame->getRsv1()) { + return $frame; // frame is already deflated + } + + if ($this->deflator === null) { + $bits = (int)$this->getDeflateWindowBits(); + if ($bits === 8) { + $bits = 9; + } + $this->deflator = deflate_init( + ZLIB_ENCODING_RAW, + [ + 'level' => -1, + 'memory' => 8, + 'window' => $bits, + 'strategy' => ZLIB_DEFAULT_STRATEGY + ] + ); + } + + // there is an issue in the zlib extension for php where + // deflate_add does not check avail_out to see if the buffer filled + // this only seems to be an issue for payloads between 16 and 64 bytes + // This if statement is a hack fix to break the output up allowing us + // to call deflate_add twice which should clear the buffer issue +// if ($frame->getPayloadLength() >= 16 && $frame->getPayloadLength() <= 64) { +// // try processing in 8 byte chunks +// // https://bugs.php.net/bug.php?id=73373 +// $payload = ""; +// $orig = $frame->getPayload(); +// $partSize = 8; +// while (strlen($orig) > 0) { +// $part = substr($orig, 0, $partSize); +// $orig = substr($orig, strlen($part)); +// $flags = strlen($orig) > 0 ? ZLIB_PARTIAL_FLUSH : ZLIB_SYNC_FLUSH; +// $payload .= deflate_add($this->deflator, $part, $flags); +// } +// } else { + $payload = deflate_add( + $this->deflator, + $frame->getPayload(), + ZLIB_SYNC_FLUSH + ); +// } + + $deflatedFrame = new Frame( + substr($payload, 0, $frame->isFinal() ? -4 : strlen($payload)), + $frame->isFinal(), + $frame->getOpcode() + ); + + if ($frame->isFinal()) { + $deflatedFrame->setRsv1(); + } + + return $deflatedFrame; + } + + /** + * This is a separate function for testing purposes + * $memory_limit is only used for testing + * + * @param null|string $memory_limit + * @return int + */ + private static function getMemoryLimit($memory_limit = null) { + $memory_limit = $memory_limit === null ? \trim(\ini_get('memory_limit')) : $memory_limit; + $memory_limit_bytes = 0; + if ($memory_limit !== '') { + $shifty = ['k' => 0, 'm' => 10, 'g' => 20]; + $multiplier = strlen($memory_limit) > 1 ? substr(strtolower($memory_limit), -1) : ''; + $memory_limit = (int)$memory_limit; + $memory_limit_bytes = in_array($multiplier, array_keys($shifty), true) ? $memory_limit * 1024 << $shifty[$multiplier] : $memory_limit; + } + + return $memory_limit_bytes < 0 ? 0 : $memory_limit_bytes; + } } diff --git a/tests/AbResultsTest.php b/tests/AbResultsTest.php index 9bc502d..9bd799e 100644 --- a/tests/AbResultsTest.php +++ b/tests/AbResultsTest.php @@ -1,7 +1,9 @@ markTestSkipped('Autobahn TestSuite results not found'); diff --git a/tests/ab/clientRunner.php b/tests/ab/clientRunner.php index e1bfd76..deec68f 100644 --- a/tests/ab/clientRunner.php +++ b/tests/ab/clientRunner.php @@ -1,34 +1,37 @@ createCached('8.8.8.8', $loop); +$connector = new Connector($loop); -$factory = new \React\SocketClient\Connector($loop, $dnsResolver); - -function echoStreamerFactory($conn) +function echoStreamerFactory($conn, $permessageDeflateOptions = null) { + $permessageDeflateOptions = $permessageDeflateOptions ?: PermessageDeflateOptions::createDisabled(); + return new \Ratchet\RFC6455\Messaging\MessageBuffer( new \Ratchet\RFC6455\Messaging\CloseFrameChecker, - function (\Ratchet\RFC6455\Messaging\MessageInterface $msg) use ($conn) { - /** @var Frame $frame */ - foreach ($msg as $frame) { - $frame->maskPayload(); - } - $conn->write($msg->getContents()); + function (\Ratchet\RFC6455\Messaging\MessageInterface $msg, MessageBuffer $messageBuffer) use ($conn) { + $messageBuffer->sendMessage($msg->getPayload(), true, $msg->isBinary()); }, - function (\Ratchet\RFC6455\Messaging\FrameInterface $frame) use ($conn) { + function (\Ratchet\RFC6455\Messaging\FrameInterface $frame, MessageBuffer $messageBuffer) use ($conn) { switch ($frame->getOpcode()) { case Frame::OP_PING: return $conn->write((new Frame($frame->getPayload(), true, Frame::OP_PONG))->maskPayload()->getContents()); @@ -38,27 +41,32 @@ function echoStreamerFactory($conn) break; } }, - false + false, + null, + null, + null, + [$conn, 'write'], + $permessageDeflateOptions ); } function getTestCases() { - global $factory; global $testServer; + global $connector; $deferred = new Deferred(); - $factory->create($testServer, 9001)->then(function (\React\Stream\Stream $stream) use ($deferred) { - $cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator(new HttpFactory()); + $connector->connect($testServer . ':9001')->then(function (ConnectionInterface $connection) use ($deferred) { + $cn = new ClientNegotiator(new HttpFactory()); $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001/getCaseCount')); $rawResponse = ""; $response = null; - /** @var \Ratchet\RFC6455\Messaging\Streaming\MessageBuffer $ms */ + /** @var MessageBuffer $ms */ $ms = null; - $stream->on('data', function ($data) use ($stream, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { + $connection->on('data', function ($data) use ($connection, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { if ($response === null) { $rawResponse .= $data; $pos = strpos($rawResponse, "\r\n\r\n"); @@ -68,17 +76,21 @@ function getTestCases() { $response = \GuzzleHttp\Psr7\parse_response($rawResponse); if (!$cn->validateResponse($cnRequest, $response)) { - $stream->end(); + $connection->end(); $deferred->reject(); } else { - $ms = new \Ratchet\RFC6455\Messaging\MessageBuffer( - new \Ratchet\RFC6455\Messaging\CloseFrameChecker, - function (\Ratchet\RFC6455\Messaging\MessageInterface $msg) use ($deferred, $stream) { + $ms = new MessageBuffer( + new CloseFrameChecker, + function (MessageInterface $msg) use ($deferred, $connection) { $deferred->resolve($msg->getPayload()); - $stream->close(); + $connection->close(); }, null, - false + false, + null, + null, + null, + function () {} ); } } @@ -90,23 +102,30 @@ function getTestCases() { } }); - $stream->write(\GuzzleHttp\Psr7\str($cnRequest)); + $connection->write(\GuzzleHttp\Psr7\str($cnRequest)); }); return $deferred->promise(); } +$cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator( + new HttpFactory(), + PermessageDeflateOptions::permessageDeflateSupported() ? PermessageDeflateOptions::createEnabled() : null); + function runTest($case) { - global $factory; + global $connector; global $testServer; + global $cn; $casePath = "/runCase?case={$case}&agent=" . AGENT; $deferred = new Deferred(); - $factory->create($testServer, 9001)->then(function (\React\Stream\Stream $stream) use ($deferred, $casePath, $case) { - $cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator(new HttpFactory()); + $connector->connect($testServer . ':9001')->then(function (ConnectionInterface $connection) use ($deferred, $casePath, $case) { + $cn = new ClientNegotiator( + new HttpFactory(), + PermessageDeflateOptions::permessageDeflateSupported() ? PermessageDeflateOptions::createEnabled() : null); $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001' . $casePath)); $rawResponse = ""; @@ -114,7 +133,7 @@ function runTest($case) $ms = null; - $stream->on('data', function ($data) use ($stream, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { + $connection->on('data', function ($data) use ($connection, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { if ($response === null) { $rawResponse .= $data; $pos = strpos($rawResponse, "\r\n\r\n"); @@ -124,10 +143,19 @@ function runTest($case) $response = \GuzzleHttp\Psr7\parse_response($rawResponse); if (!$cn->validateResponse($cnRequest, $response)) { - $stream->end(); + echo "Invalid response.\n"; + $connection->end(); $deferred->reject(); } else { - $ms = echoStreamerFactory($stream); + try { + $permessageDeflateOptions = PermessageDeflateOptions::fromRequestOrResponse($response)[0]; + $ms = echoStreamerFactory( + $connection, + $permessageDeflateOptions + ); + } catch (InvalidPermessageDeflateOptionsException $e) { + $connection->end(); + } } } } @@ -138,34 +166,36 @@ function runTest($case) } }); - $stream->on('close', function () use ($deferred) { + $connection->on('close', function () use ($deferred) { $deferred->resolve(); }); - $stream->write(\GuzzleHttp\Psr7\str($cnRequest)); + $connection->write(\GuzzleHttp\Psr7\str($cnRequest)); }); return $deferred->promise(); } function createReport() { - global $factory; + global $connector; global $testServer; $deferred = new Deferred(); - $factory->create($testServer, 9001)->then(function (\React\Stream\Stream $stream) use ($deferred) { - $reportPath = "/updateReports?agent=" . AGENT . "&shutdownOnComplete=true"; - $cn = new \Ratchet\RFC6455\Handshake\ClientNegotiator(new HttpFactory()); + $connector->connect($testServer . ':9001')->then(function (ConnectionInterface $connection) use ($deferred) { + // $reportPath = "/updateReports?agent=" . AGENT . "&shutdownOnComplete=true"; + // we will stop it using docker now instead of just shutting down + $reportPath = "/updateReports?agent=" . AGENT; + $cn = new ClientNegotiator(new HttpFactory()); $cnRequest = $cn->generateRequest(new Uri('ws://127.0.0.1:9001' . $reportPath)); $rawResponse = ""; $response = null; - /** @var \Ratchet\RFC6455\Messaging\MessageBuffer $ms */ + /** @var MessageBuffer $ms */ $ms = null; - $stream->on('data', function ($data) use ($stream, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { + $connection->on('data', function ($data) use ($connection, &$rawResponse, &$response, &$ms, $cn, $deferred, &$context, $cnRequest) { if ($response === null) { $rawResponse .= $data; $pos = strpos($rawResponse, "\r\n\r\n"); @@ -175,17 +205,21 @@ function createReport() { $response = \GuzzleHttp\Psr7\parse_response($rawResponse); if (!$cn->validateResponse($cnRequest, $response)) { - $stream->end(); + $connection->end(); $deferred->reject(); } else { - $ms = new \Ratchet\RFC6455\Messaging\MessageBuffer( - new \Ratchet\RFC6455\Messaging\CloseFrameChecker, - function (\Ratchet\RFC6455\Messaging\MessageInterface $msg) use ($deferred, $stream) { + $ms = new MessageBuffer( + new CloseFrameChecker, + function (MessageInterface $msg) use ($deferred, $connection) { $deferred->resolve($msg->getPayload()); - $stream->close(); + $connection->close(); }, null, - false + false, + null, + null, + null, + function () {} ); } } @@ -197,7 +231,7 @@ function createReport() { } }); - $stream->write(\GuzzleHttp\Psr7\str($cnRequest)); + $connection->write(\GuzzleHttp\Psr7\str($cnRequest)); }); return $deferred->promise(); @@ -215,7 +249,13 @@ getTestCases()->then(function ($count) use ($loop) { $allDeferred->resolve(); return; } - runTest($i)->then($runNextCase); + echo "Running test $i/$count..."; + $startTime = microtime(true); + runTest($i) + ->then(function () use ($startTime) { + echo " completed " . round((microtime(true) - $startTime) * 1000) . " ms\n"; + }) + ->then($runNextCase); }; $i = 0; diff --git a/tests/ab/docker_bootstrap.sh b/tests/ab/docker_bootstrap.sh new file mode 100644 index 0000000..44d4581 --- /dev/null +++ b/tests/ab/docker_bootstrap.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -x + +echo "Running $0" + +echo Adding "$1 host.ratchet.internal" to /etc/hosts file + +echo $1 host.ratchet.internal >> /etc/hosts + +echo /etc/hosts contains: +cat /etc/hosts +echo diff --git a/tests/ab/fuzzingclient.json b/tests/ab/fuzzingclient.json index d2fd0d0..d410be3 100644 --- a/tests/ab/fuzzingclient.json +++ b/tests/ab/fuzzingclient.json @@ -2,13 +2,15 @@ "options": { "failByDrop": false } - , "outdir": "./reports/servers" + , "outdir": "/reports/servers" , "servers": [{ - "agent": "RatchetRFC/0.1.0" - , "url": "ws://localhost:9001" + "agent": "RatchetRFC/0.3" + , "url": "ws://host.ratchet.internal:9001" , "options": {"version": 18} }] - , "cases": ["*"] - , "exclude-cases": ["6.4.*", "12.*","13.*"] + , "cases": [ + "*" + ] + , "exclude-cases": [] , "exclude-agent-cases": {} } diff --git a/tests/ab/fuzzingclient_skip_deflate.json b/tests/ab/fuzzingclient_skip_deflate.json new file mode 100644 index 0000000..b1fddbe --- /dev/null +++ b/tests/ab/fuzzingclient_skip_deflate.json @@ -0,0 +1,14 @@ +{ + "options": { + "failByDrop": false + } + , "outdir": "/reports/servers" + , "servers": [{ + "agent": "RatchetRFC/0.3" + , "url": "ws://host.ratchet.internal:9001" + , "options": {"version": 18} + }] + , "cases": ["*"] + , "exclude-cases": ["12.*", "13.*"] + , "exclude-agent-cases": {} +} diff --git a/tests/ab/fuzzingserver.json b/tests/ab/fuzzingserver.json index 0422560..3a59bab 100644 --- a/tests/ab/fuzzingserver.json +++ b/tests/ab/fuzzingserver.json @@ -4,7 +4,9 @@ "failByDrop": false } , "outdir": "./reports/clients" - , "cases": ["*"] - , "exclude-cases": ["6.4.*", "12.*", "13.*"] + , "cases": [ + "*" + ] + , "exclude-cases": [] , "exclude-agent-cases": {} } diff --git a/tests/ab/fuzzingserver_skip_deflate.json b/tests/ab/fuzzingserver_skip_deflate.json new file mode 100644 index 0000000..3b90fc3 --- /dev/null +++ b/tests/ab/fuzzingserver_skip_deflate.json @@ -0,0 +1,10 @@ +{ + "url": "ws://127.0.0.1:9001" + , "options": { + "failByDrop": false + } + , "outdir": "./reports/clients" + , "cases": ["*"] + , "exclude-cases": ["12.*", "13.*"] + , "exclude-agent-cases": {} +} diff --git a/tests/ab/run_ab_tests.sh b/tests/ab/run_ab_tests.sh index 8fa9ced..b924480 100755 --- a/tests/ab/run_ab_tests.sh +++ b/tests/ab/run_ab_tests.sh @@ -1,11 +1,58 @@ +set -x cd tests/ab -wstest -m fuzzingserver -s fuzzingserver.json & -sleep 5 -php clientRunner.php +SKIP_DEFLATE= +if [ "$TRAVIS" = "true" ]; then +if [ $(phpenv version-name) = "hhvm" -o $(phpenv version-name) = "5.4" -o $(phpenv version-name) = "5.5" -o $(phpenv version-name) = "5.6" ]; then + echo "Skipping deflate autobahn tests for $(phpenv version-name)" + SKIP_DEFLATE=_skip_deflate +fi +fi + +if [ "$ABTEST" = "client" ]; then + docker run --rm \ + -d \ + -v ${PWD}:/config \ + -v ${PWD}/reports:/reports \ + -p 9001:9001 \ + --name fuzzingserver \ + crossbario/autobahn-testsuite wstest -m fuzzingserver -s /config/fuzzingserver$SKIP_DEFLATE.json + sleep 5 + if [ "$TRAVIS" != "true" ]; then + echo "Running tests vs Autobahn test client" + ###docker run -it --rm --name abpytest crossbario/autobahn-testsuite wstest --mode testeeclient -w ws://host.docker.internal:9001 + fi + php -d memory_limit=256M clientRunner.php + + docker ps -a + + docker logs fuzzingserver + + docker stop fuzzingserver + + sleep 2 +fi + +if [ "$ABTEST" = "server" ]; then + php -d memory_limit=256M startServer.php & + sleep 3 + + if [ "$OSTYPE" = "linux-gnu" ]; then + IPADDR=`hostname -I | cut -f 1 -d ' '` + else + IPADDR=`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -1 | tr -d 'adr:'` + fi + + docker run --rm \ + -it \ + -v ${PWD}:/config \ + -v ${PWD}/reports:/reports \ + --name fuzzingclient \ + crossbario/autobahn-testsuite /bin/sh -c "sh /config/docker_bootstrap.sh $IPADDR; wstest -m fuzzingclient -s /config/fuzzingclient$SKIP_DEFLATE.json" + sleep 1 + + # send the shutdown command to the PHP echo server + wget -O - -q http://127.0.0.1:9001/shutdown +fi -sleep 2 -php startServer.php & -sleep 3 -wstest -m fuzzingclient -s fuzzingclient.json diff --git a/tests/ab/startServer.php b/tests/ab/startServer.php index a5cd78c..e86cc39 100644 --- a/tests/ab/startServer.php +++ b/tests/ab/startServer.php @@ -1,4 +1,8 @@ on('request', function (\React\Http\Request $request, \React\Http\Response $response) use ($negotiator, $closeFrameChecker, $uException) { - $psrRequest = new \GuzzleHttp\Psr7\Request($request->getMethod(), $request->getPath(), $request->getHeaders()); - $negotiatorResponse = $negotiator->handshake($psrRequest); - - $response->writeHead( - $negotiatorResponse->getStatusCode(), - array_merge( - $negotiatorResponse->getHeaders(), - ["Content-Length" => "0"] - ) - ); - - if ($negotiatorResponse->getStatusCode() !== 101) { - $response->end(); - return; - } - - $parser = new \Ratchet\RFC6455\Messaging\MessageBuffer($closeFrameChecker, function(MessageInterface $message) use ($response) { - $response->write($message->getContents()); - }, function(FrameInterface $frame) use ($response, &$parser) { - switch ($frame->getOpCode()) { - case Frame::OP_CLOSE: - $response->end($frame->getContents()); - break; - case Frame::OP_PING: - $response->write($parser->newFrame($frame->getPayload(), true, Frame::OP_PONG)->getContents()); - break; +$socket->on('connection', function (React\Socket\ConnectionInterface $connection) use ($negotiator, $closeFrameChecker, $uException, $socket) { + $headerComplete = false; + $buffer = ''; + $parser = null; + $connection->on('data', function ($data) use ($connection, &$parser, &$headerComplete, &$buffer, $negotiator, $closeFrameChecker, $uException, $socket) { + if ($headerComplete) { + $parser->onData($data); + return; } - }, true, function() use ($uException) { - return $uException; - }); - $request->on('data', [$parser, 'onData']); + $buffer .= $data; + $parts = explode("\r\n\r\n", $buffer); + if (count($parts) < 2) { + return; + } + $headerComplete = true; + $psrRequest = \GuzzleHttp\Psr7\parse_request($parts[0] . "\r\n\r\n"); + $negotiatorResponse = $negotiator->handshake($psrRequest); + + $negotiatorResponse = $negotiatorResponse->withAddedHeader("Content-Length", "0"); + + if ($negotiatorResponse->getStatusCode() !== 101 && $psrRequest->getUri()->getPath() === '/shutdown') { + $connection->end(\GuzzleHttp\Psr7\str(new Response(200, [], 'Shutting down echo server.' . PHP_EOL))); + $socket->close(); + return; + }; + + $connection->write(\GuzzleHttp\Psr7\str($negotiatorResponse)); + + if ($negotiatorResponse->getStatusCode() !== 101) { + $connection->end(); + return; + } + + // there is no need to look through the client requests + // we support any valid permessage deflate + $deflateOptions = PermessageDeflateOptions::fromRequestOrResponse($psrRequest)[0]; + + $parser = new \Ratchet\RFC6455\Messaging\MessageBuffer($closeFrameChecker, + function (MessageInterface $message, MessageBuffer $messageBuffer) { + $messageBuffer->sendMessage($message->getPayload(), true, $message->isBinary()); + }, function (FrameInterface $frame) use ($connection, &$parser) { + switch ($frame->getOpCode()) { + case Frame::OP_CLOSE: + $connection->end($frame->getContents()); + break; + case Frame::OP_PING: + $connection->write($parser->newFrame($frame->getPayload(), true, Frame::OP_PONG)->getContents()); + break; + } + }, true, function () use ($uException) { + return $uException; + }, + null, + null, + [$connection, 'write'], + $deflateOptions); + + array_shift($parts); + $parser->onData(implode("\r\n\r\n", $parts)); + }); }); -$socket->listen(9001, '0.0.0.0'); $loop->run(); diff --git a/tests/unit/Handshake/PermessageDeflateOptionsTest.php b/tests/unit/Handshake/PermessageDeflateOptionsTest.php new file mode 100644 index 0000000..11d3739 --- /dev/null +++ b/tests/unit/Handshake/PermessageDeflateOptionsTest.php @@ -0,0 +1,30 @@ +assertEquals($supported, PermessageDeflateOptions::permessageDeflateSupported($version)); + } +} \ No newline at end of file diff --git a/tests/unit/Handshake/RequestVerifierTest.php b/tests/unit/Handshake/RequestVerifierTest.php index 239de33..5ba26b6 100644 --- a/tests/unit/Handshake/RequestVerifierTest.php +++ b/tests/unit/Handshake/RequestVerifierTest.php @@ -1,11 +1,14 @@ assertTrue($bReceived); } + + public function testInvalidFrameLength() { + $frame = new Frame(str_repeat('a', 200), true, Frame::OP_TEXT); + + $frameRaw = $frame->getContents(); + + $frameRaw[1] = "\x7f"; // 127 in the first spot + + $frameRaw[2] = "\xff"; // this will unpack to -1 + $frameRaw[3] = "\xff"; + $frameRaw[4] = "\xff"; + $frameRaw[5] = "\xff"; + $frameRaw[6] = "\xff"; + $frameRaw[7] = "\xff"; + $frameRaw[8] = "\xff"; + $frameRaw[9] = "\xff"; + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 0, + 10 + ); + + $messageBuffer->onData($frameRaw); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_PROTOCOL], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + + } + + public function testFrameLengthTooBig() { + $frame = new Frame(str_repeat('a', 200), true, Frame::OP_TEXT); + + $frameRaw = $frame->getContents(); + + $frameRaw[1] = "\x7f"; // 127 in the first spot + + $frameRaw[2] = "\x7f"; // this will unpack to -1 + $frameRaw[3] = "\xff"; + $frameRaw[4] = "\xff"; + $frameRaw[5] = "\xff"; + $frameRaw[6] = "\xff"; + $frameRaw[7] = "\xff"; + $frameRaw[8] = "\xff"; + $frameRaw[9] = "\xff"; + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 0, + 10 + ); + + $messageBuffer->onData($frameRaw); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_TOO_BIG], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + } + + public function testFrameLengthBiggerThanMaxMessagePayload() { + $frame = new Frame(str_repeat('a', 200), true, Frame::OP_TEXT); + + $frameRaw = $frame->getContents(); + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 100, + 0 + ); + + $messageBuffer->onData($frameRaw); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_TOO_BIG], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + } + + public function testSecondFrameLengthPushesPastMaxMessagePayload() { + $frame = new Frame(str_repeat('a', 200), false, Frame::OP_TEXT); + $firstFrameRaw = $frame->getContents(); + $frame = new Frame(str_repeat('b', 200), true, Frame::OP_TEXT); + $secondFrameRaw = $frame->getContents(); + + /** @var Frame $controlFrame */ + $controlFrame = null; + $messageCount = 0; + + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) use (&$messageCount) { + $messageCount++; + }, + function (Frame $frame) use (&$controlFrame) { + $this->assertNull($controlFrame); + $controlFrame = $frame; + }, + false, + null, + 300, + 0 + ); + + $messageBuffer->onData($firstFrameRaw); + // only put part of the second frame in to watch it fail fast + $messageBuffer->onData(substr($secondFrameRaw, 0, 150)); + + $this->assertEquals(0, $messageCount); + $this->assertTrue($controlFrame instanceof Frame); + $this->assertEquals(Frame::OP_CLOSE, $controlFrame->getOpcode()); + $this->assertEquals([Frame::CLOSE_TOO_BIG], array_merge(unpack('n*', substr($controlFrame->getPayload(), 0, 2)))); + } + + /** + * Some test cases from memory limit inspired by https://github.com/BrandEmbassy/php-memory + * + * Here is the license for that project: + * MIT License + * + * Copyright (c) 2018 Brand Embassy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + + /** + * @dataProvider phpConfigurationProvider + * + * @param string $phpConfigurationValue + * @param int $expectedLimit + */ + public function testMemoryLimits($phpConfigurationValue, $expectedLimit) { + $method = new \ReflectionMethod('Ratchet\RFC6455\Messaging\MessageBuffer', 'getMemoryLimit'); + $method->setAccessible(true); + $actualLimit = $method->invoke(null, $phpConfigurationValue); + + $this->assertSame($expectedLimit, $actualLimit); + } + + public function phpConfigurationProvider() { + return [ + 'without unit type, just bytes' => ['500', 500], + '1 GB with big "G"' => ['1G', 1 * 1024 * 1024 * 1024], + '128 MB with big "M"' => ['128M', 128 * 1024 * 1024], + '128 MB with small "m"' => ['128m', 128 * 1024 * 1024], + '24 kB with small "k"' => ['24k', 24 * 1024], + '2 GB with small "g"' => ['2g', 2 * 1024 * 1024 * 1024], + 'unlimited memory' => ['-1', 0], + 'invalid float value' => ['2.5M', 2 * 1024 * 1024], + 'empty value' => ['', 0], + 'invalid ini setting' => ['whatever it takes', 0] + ]; + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testInvalidMaxFramePayloadSizes() { + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null, + 0, + 0x8000000000000000 + ); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testInvalidMaxMessagePayloadSizes() { + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null, + 0x8000000000000000, + 0 + ); + } + + /** + * @dataProvider phpConfigurationProvider + * + * @param string $phpConfigurationValue + * @param int $expectedLimit + * + * @runInSeparateProcess + * @requires PHP 7.0 + */ + public function testIniSizes($phpConfigurationValue, $expectedLimit) { + ini_set('memory_limit', $phpConfigurationValue); + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null + ); + + if ($expectedLimit === -1) { + $expectedLimit = 0; + } + + $prop = new \ReflectionProperty($messageBuffer, 'maxMessagePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals($expectedLimit / 4, $prop->getValue($messageBuffer)); + + $prop = new \ReflectionProperty($messageBuffer, 'maxFramePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals($expectedLimit / 4, $prop->getValue($messageBuffer)); + } + + /** + * @runInSeparateProcess + * @requires PHP 7.0 + */ + public function testInvalidIniSize() { + ini_set('memory_limit', 'lots of memory'); + $messageBuffer = new MessageBuffer( + new CloseFrameChecker(), + function (Message $message) {}, + function (Frame $frame) {}, + false, + null + ); + + $prop = new \ReflectionProperty($messageBuffer, 'maxMessagePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals(0, $prop->getValue($messageBuffer)); + + $prop = new \ReflectionProperty($messageBuffer, 'maxFramePayloadSize'); + $prop->setAccessible(true); + $this->assertEquals(0, $prop->getValue($messageBuffer)); + } } \ No newline at end of file diff --git a/tests/unit/Messaging/MessageTest.php b/tests/unit/Messaging/MessageTest.php index 1f7eab5..c307da8 100644 --- a/tests/unit/Messaging/MessageTest.php +++ b/tests/unit/Messaging/MessageTest.php @@ -1,12 +1,15 @@