From 5653f01f2fd18112486415221ddbcdea8a0ce826 Mon Sep 17 00:00:00 2001 From: Chris Boden Date: Fri, 18 Nov 2011 16:37:32 -0500 Subject: [PATCH] Message buffering & Refactoring Refactored unframe() methods into Message/Frame classes (per protocol version) Change onRecv of WebSocket App to use new interfaces to test statuses, resulting in reuniting a message fragmented by TCP Wrote unit test covering most of new HyBi10 Frame class --- lib/Ratchet/Application/WebSocket/App.php | 35 ++- .../{ => Version}/FrameInterface.php | 20 +- .../Application/WebSocket/Version/Hixie76.php | 15 + .../WebSocket/Version/Hixie76/Frame.php | 58 ++++ .../WebSocket/Version/Hixie76/Message.php | 48 ++++ .../Application/WebSocket/Version/HyBi10.php | 16 ++ .../WebSocket/Version/HyBi10/Frame.php | 203 ++++++++++++++ .../WebSocket/Version/HyBi10/Message.php | 63 +++++ .../{ => Version}/MessageInterface.php | 9 +- .../WebSocket/Version/VersionInterface.php | 11 + .../WebSocket/Version/HyBi10/FrameTest.php | 256 ++++++++++++++++++ 11 files changed, 721 insertions(+), 13 deletions(-) rename lib/Ratchet/Application/WebSocket/{ => Version}/FrameInterface.php (56%) create mode 100644 lib/Ratchet/Application/WebSocket/Version/Hixie76/Frame.php create mode 100644 lib/Ratchet/Application/WebSocket/Version/Hixie76/Message.php create mode 100644 lib/Ratchet/Application/WebSocket/Version/HyBi10/Frame.php create mode 100644 lib/Ratchet/Application/WebSocket/Version/HyBi10/Message.php rename lib/Ratchet/Application/WebSocket/{ => Version}/MessageInterface.php (74%) create mode 100644 tests/Ratchet/Tests/Application/WebSocket/Version/HyBi10/FrameTest.php diff --git a/lib/Ratchet/Application/WebSocket/App.php b/lib/Ratchet/Application/WebSocket/App.php index 2a75987..b398d8d 100644 --- a/lib/Ratchet/Application/WebSocket/App.php +++ b/lib/Ratchet/Application/WebSocket/App.php @@ -101,14 +101,35 @@ class App implements ApplicationInterface, ConfiguratorInterface { return $comp; } - // buffer! - - $msg = $from->WebSocket->version->unframe($msg); - if (is_array($msg)) { // temporary - $msg = $msg['payload']; + if (!isset($from->WebSocket->message)) { + $from->WebSocket->message = $from->WebSocket->version->newMessage(); } - return $this->prepareCommand($this->_app->onRecv($from, $msg)); + // There is a frame fragment attatched to the connection, add to it + if (!isset($from->WebSocket->frame)) { + $from->WebSocket->frame = $from->WebSocket->version->newFrame(); + } + + $from->WebSocket->frame->addBuffer($msg); + if ($from->WebSocket->frame->isCoalesced()) { + if ($from->WebSocket->frame->getOpcode() > 2) { + throw new \UnexpectedValueException('Control frame support coming soon!'); + } + // Check frame + // If is control frame, do your thing + // Else, add to message + // Control frames (ping, pong, close) can be sent in between a fragmented message + + $from->WebSocket->message->addFrame($from->WebSocket->frame); + unset($from->WebSocket->frame); + } + + if ($from->WebSocket->message->isCoalesced()) { + $cmds = $this->prepareCommand($this->_app->onRecv($from, (string)$from->WebSocket->message)); + unset($from->WebSocket->message); + + return $cmds; + } } public function onClose(Connection $conn) { @@ -153,7 +174,7 @@ class App implements ApplicationInterface, ConfiguratorInterface { * @todo Can/will add more versions later, but perhaps a chain of responsibility, ask each version if they want to handle the request */ protected function getVersion($message) { - if (false === strstr($message, "\r\n\r\n")) { // This _could_ fail with Hixie + if (false === strstr($message, "\r\n\r\n")) { // This CAN fail with Hixie, depending on the TCP buffer in between throw new \UnderflowException; } diff --git a/lib/Ratchet/Application/WebSocket/FrameInterface.php b/lib/Ratchet/Application/WebSocket/Version/FrameInterface.php similarity index 56% rename from lib/Ratchet/Application/WebSocket/FrameInterface.php rename to lib/Ratchet/Application/WebSocket/Version/FrameInterface.php index 5b500e5..3056094 100644 --- a/lib/Ratchet/Application/WebSocket/FrameInterface.php +++ b/lib/Ratchet/Application/WebSocket/Version/FrameInterface.php @@ -1,7 +1,18 @@ getPayload(); + } + + public function isCoalesced() { + return (boolean)($this->_data[0] == chr(0) && substr($this->_data, -1) == chr(255)); + } + + public function addBuffer($buf) { + $this->_data .= (string)$buf; + } + + public function isFinal() { + return true; + } + + public function isMasked() { + return false; + } + + public function getOpcode() { + return 1; + } + + public function getPayloadLength() { + if (!$this->isCoalesced()) { + throw new \UnderflowException('Not enough of the message has been buffered to determine the length of the payload'); + } + + return strlen($this->_data) - 2; + } + + public function getMaskingKey() { + return ''; + } + + public function getPayload() { + if (!$this->isCoalesced()) { + return new \UnderflowException('Not enough data buffered to read payload'); + } + + return substr($this->_data, 1, strlen($this->_data) - 2); + } +} \ No newline at end of file diff --git a/lib/Ratchet/Application/WebSocket/Version/Hixie76/Message.php b/lib/Ratchet/Application/WebSocket/Version/Hixie76/Message.php new file mode 100644 index 0000000..6366c22 --- /dev/null +++ b/lib/Ratchet/Application/WebSocket/Version/Hixie76/Message.php @@ -0,0 +1,48 @@ +getPayload(); + } + + public function isCoalesced() { + if (!($this->_frame instanceof FrameInterface)) { + return false; + } + + return $this->_frame->isCoalesced(); + } + + public function addFrame(FrameInterface $fragment) { + if (null !== $this->_frame) { + throw new \OverflowException('Hixie76 does not support multiple framing of messages'); + } + + $this->_frame = $fragment; + } + + public function getOpcode() { + // Hixie76 only supported text messages + return 1; + } + + public function getPayloadLength() { + throw new \DomainException('Please sir, may I have some code? (' . __FUNCTION__ . ')'); + } + + public function getPayload() { + if (!$this->isCoalesced()) { + throw new \UnderflowException('Message has not been fully buffered yet'); + } + + return $this->_frame->getPayload(); + } +} \ No newline at end of file diff --git a/lib/Ratchet/Application/WebSocket/Version/HyBi10.php b/lib/Ratchet/Application/WebSocket/Version/HyBi10.php index 6077879..e4bba6d 100644 --- a/lib/Ratchet/Application/WebSocket/Version/HyBi10.php +++ b/lib/Ratchet/Application/WebSocket/Version/HyBi10.php @@ -5,12 +5,14 @@ use Ratchet\Application\WebSocket\Util\HTTP; /** * The HyBi-10 version, identified in the headers as version 8, is currently implemented by the latest Chrome and Firefix version * @link http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-10 + * @todo Naming...I'm not fond of this naming convention... */ class HyBi10 implements VersionInterface { const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; /** * @return array + * I kept this as an array and combined in App for future considerations...easier to add a subprotol as a key value than edit a string */ public function handshake($message) { $headers = HTTP::getHeaders($message); @@ -25,6 +27,20 @@ class HyBi10 implements VersionInterface { ); } + /** + * @return HyBi10\Message + */ + public function newMessage() { + return new HyBi10\Message; + } + + /** + * @return HyBi10\Frame + */ + public function newFrame() { + return new HyBi10\Frame; + } + /** * Unframe a message received from the client * Thanks to @lemmingzshadow for the code on decoding a HyBi-10 frame diff --git a/lib/Ratchet/Application/WebSocket/Version/HyBi10/Frame.php b/lib/Ratchet/Application/WebSocket/Version/HyBi10/Frame.php new file mode 100644 index 0000000..5223a96 --- /dev/null +++ b/lib/Ratchet/Application/WebSocket/Version/HyBi10/Frame.php @@ -0,0 +1,203 @@ +getPayload(); + } + + public function isCoalesced() { + try { + $payload_length = $this->getPayloadLength(); + $payload_start = $this->getPayloadStartingByte(); + } catch (\UnderflowException $e) { + return false; + } + + return $payload_length + $payload_start === $this->_bytes_rec; + } + + public function addBuffer($buf) { + $buf = (string)$buf; + + $this->_data .= $buf; + $this->_bytes_rec += strlen($buf); + } + + public function isFinal() { + if ($this->_bytes_rec < 1) { + throw new \UnderflowException('Not enough bytes received to determine if this is the final frame in message'); + } + + $fbb = sprintf('%08b', ord($this->_data[0])); + return (boolean)(int)$fbb[0]; + } + + public function isMasked() { + if ($this->_bytes_rec < 2) { + throw new \UnderflowException("Not enough bytes received ({$this->_bytes_rec}) to determine if mask is set"); + } + + return (boolean)bindec(substr(sprintf('%08b', ord($this->_data[1])), 0, 1)); + } + + public function getOpcode() { + if ($this->_bytes_rec < 1) { + throw new \UnderflowException('Not enough bytes received to determine opcode'); + } + + return bindec(substr(sprintf('%08b', ord($this->_data[0])), 4, 4)); + } + + /** + * Gets the decimal value of bits 9 (10th) through 15 inclusive + * @return int + * @throws UnderflowException If the buffer doesn't have enough data to determine this + */ + protected function getFirstPayloadVal() { + if ($this->_bytes_rec < 2) { + throw new \UnderflowException('Not enough bytes received'); + } + + return ord($this->_data[1]) & 127; + } + + /** + * @return int (7|23|71) Number of bits defined for the payload length in the fame + * @throws UnderflowException + */ + protected function getNumPayloadBits() { + if ($this->_bytes_rec < 2) { + throw new \UnderflowException('Not enough bytes received'); + } + + // By default 7 bits are used to describe the payload length + // These are bits 9 (10th) through 15 inclusive + $bits = 7; + + // Get the value of those bits + $check = $this->getFirstPayloadVal(); + + // If the value is 126 the 7 bits plus the next 16 are used to describe the payload length + if ($check >= 126) { + $bits += 16; + } + + // If the value of the initial payload length are is 127 an additional 48 bits are used to describe length + // Note: The documentation specifies the length is to be 63 bits, but I think that's a type and is 64 (16+48) + if ($check === 127) { + $bits += 48; + } + + if (!in_array($bits, array(7, 23, 71))) { + throw new \UnexpectedValueException("Malformed frame, invalid payload length provided"); + } + + return $bits; + } + + /** + * This just returns the number of bytes used in the frame to describe the payload length (as opposed to # of bits) + * @see getNumPayloadBits + */ + protected function getNumPayloadBytes() { + return (1 + $this->getNumPayloadBits()) / 8; + } + + public function getPayloadLength() { + if ($this->_pay_len_def !== -1) { + return $this->_pay_len_def; + } + + $length_check = $this->getFirstPayloadVal(); + + if ($length_check <= 125) { + $this->_pay_len_def = $length_check; + return $this->getPayloadLength(); + } + + $byte_length = $this->getNumPayloadBytes(); + if ($this->_bytes_rec < 1 + $byte_length) { + throw new \UnderflowException('Not enough data buffered to determine payload length'); + } + + $strings = array(); + for ($i = 2; $i < $byte_length + 1; $i++) { + $strings[] = ord($this->_data[$i]); + } + + $this->_pay_len_def = bindec(vsprintf(str_repeat('%08b', $byte_length - 1), $strings)); + return $this->getPayloadLength(); + } + + public function getMaskingKey() { + if (!$this->isMasked()) { + return ''; + } + + $length = 4; + $start = 1 + $this->getNumPayloadBytes(); + + if ($this->_bytes_rec < $start + $length) { + throw new \UnderflowException('Not enough data buffered to calculate the masking key'); + } + + return substr($this->_data, $start, $length); + } + + public function getPayloadStartingByte() { + return 1 + $this->getNumPayloadBytes() + strlen($this->getMaskingKey()); + } + + public function getPayload() { + if (!$this->isCoalesced()) { + throw new \UnderflowException('Can not return partial message'); + } + + $payload = ''; + $length = $this->getPayloadLength(); + + if ($this->isMasked()) { + $mask = $this->getMaskingKey(); + $start = $this->getPayloadStartingByte(); + + for ($i = 0; $i < $length; $i++) { + $payload .= $this->_data[$i + $start] ^ $mask[$i % 4]; + } + } else { + $payload = substr($this->_data, $start, $this->getPayloadLength()); + } + + if (strlen($payload) !== $length) { + // Is this possible? isCoalesced() math _should_ ensure if there is mal-formed data, it would return false + throw new \UnexpectedValueException('Payload length does not match expected length'); + } + + return $payload; + } +} \ No newline at end of file diff --git a/lib/Ratchet/Application/WebSocket/Version/HyBi10/Message.php b/lib/Ratchet/Application/WebSocket/Version/HyBi10/Message.php new file mode 100644 index 0000000..ccca59f --- /dev/null +++ b/lib/Ratchet/Application/WebSocket/Version/HyBi10/Message.php @@ -0,0 +1,63 @@ +_frames = new \SplDoublyLinkedList; + } + + public function __toString() { + return $this->getPayload(); + } + + public function isCoalesced() { + if (count($this->_frames) == 0) { + return false; + } + + $last = $this->_frames->top(); + + return ($last->isCoalesced() && $last->isFinal()); + } + + /** + * @todo Should I allow addFrame if the frame is not coalesced yet? I believe I'm assuming this class will only receive fully formed frame messages + * @todo Also, I should perhaps check the type...control frames (ping/pong/close) are not to be considered part of a message + */ + public function addFrame(FrameInterface $fragment) { + $this->_frames->push($fragment); + } + + public function getOpcode() { + if (count($this->_frames) == 0) { + throw new \UnderflowException('No frames have been added to this message'); + } + + return $this->_frames->bottom()->getOpcode(); + } + + public function getPayloadLength() { + throw new \DomainException('Please sir, may I have some code? (' . __FUNCTION__ . ')'); + } + + public function getPayload() { + if (!$this->isCoalesced()) { + throw new \UnderflowMessage('Message has not been put back together yet'); + } + + $buffer = ''; + + foreach ($this->_frames as $frame) { + $buffer .= (string)$frame; + } + + return $buffer; + } +} \ No newline at end of file diff --git a/lib/Ratchet/Application/WebSocket/MessageInterface.php b/lib/Ratchet/Application/WebSocket/Version/MessageInterface.php similarity index 74% rename from lib/Ratchet/Application/WebSocket/MessageInterface.php rename to lib/Ratchet/Application/WebSocket/Version/MessageInterface.php index 9053fb2..57cba5d 100644 --- a/lib/Ratchet/Application/WebSocket/MessageInterface.php +++ b/lib/Ratchet/Application/WebSocket/Version/MessageInterface.php @@ -1,10 +1,15 @@ _frame = new Frame; + } + + protected static function convert($in) { + return pack('C', bindec($in)); + } + + /** + * This is a data provider + * @param string The UTF8 message + * @param string The WebSocket framed message, then base64_encoded + */ + public static function UnframeMessageProvider() { + return array( + array('Hello World!', 'gYydAIfa1WXrtvIg0LXvbOP7') + , array('!@#$%^&*()-=_+[]{}\|/.,<>`~', 'gZv+h96r38f9j9vZ+IHWrvOWoayF9oX6gtfRqfKXwOeg') + , array('ಠ_ಠ', 'gYfnSpu5B/g75gf4Ow==') + , array("The quick brown fox jumps over the lazy dog. All work and no play makes Chris a dull boy. I'm trying to get past 128 characters for a unit test here...", 'gf4Amahb14P8M7Kj2S6+4MN7tfHHLLmjzjSvo8IuuvPbe7j1zSn398A+9+/JIa6jzDSwrYh7lu/Ee6Ds2jD34sY/9+3He6fvySL37skwsvCIGL/xwSj34og/ou/Ee7Xs0XX3o+F8uqPcKa7qxjz398d7sObce6fi2y/3sppj9+DAOqXiyy+y8dt7sezae7aj3TW+94gvsvDce7/m2j75rYY=') + ); + } + + /** + * A data provider for testing the first byte of a WebSocket frame + * @param bool Given, is the byte indicate this is the final frame + * @param int Given, what is the expected opcode + * @param string of 0|1 Each character represents a bit in the byte + */ + public static function firstByteProvider() { + return array( + array(false, 8, '00001000') + , array(true, 10, '10001010') + , array(false, 15, '00001111') + , array(true, 1, '10000001') + , array(true, 15, '11111111') + ); + } + + public function testUnderflowExceptionFromAllTheMethodsMimickingBuffering() { + return $this->markTestIncomplete(); + + $this->expectException('\UnderflowException'); + } + + /** + * @dataProvider firstByteProvider + */ + public function testFinCodeFromBits($fin, $opcode, $bin) { + $this->_frame->addBuffer(static::convert($bin)); + $this->assertEquals($fin, $this->_frame->isFinal()); + } + + /** + * @dataProvider UnframeMessageProvider + */ + public function testFinCodeFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertTrue($this->_frame->isFinal()); + } + + /** + * @dataProvider firstByteProvider + */ + public function testOpcodeFromBits($fin, $opcode, $bin) { + $this->_frame->addBuffer(static::convert($bin)); + $this->assertEquals($opcode, $this->_frame->getOpcode()); + } + + /** + * @dataProvider UnframeMessageProvider + */ + public function testOpcodeFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertEquals(1, $this->_frame->getOpcode()); + } + + public static function payloadLengthDescriptionProvider() { + return array( + array(7, '01110101') + , array(7, '01111101') + , array(23, '01111110') + , array(71, '01111111') + , array(7, '00000000') // Should this throw an exception? Can a payload be empty? + , array(7, '00000001') + ); + } + + /** + * @dataProvider payloadLengthDescriptionProvider + */ + public function testFirstPayloadDesignationValue($bits, $bin) { + $this->_frame->addBuffer(static::convert($this->_firstByteFinText)); + $this->_frame->addBuffer(static::convert($bin)); + + $ref = new \ReflectionClass($this->_frame); + $cb = $ref->getMethod('getFirstPayloadVal'); + $cb->setAccessible(true); + + $this->assertEquals(bindec($bin), $cb->invoke($this->_frame)); + } + + /** + * @dataProvider payloadLengthDescriptionProvider + */ + public function testDetermineHowManyBitsAreUsedToDescribePayload($expected_bits, $bin) { + $this->_frame->addBuffer(static::convert($this->_firstByteFinText)); + $this->_frame->addBuffer(static::convert($bin)); + + $ref = new \ReflectionClass($this->_frame); + $cb = $ref->getMethod('getNumPayloadBits'); + $cb->setAccessible(true); + + $this->assertEquals($expected_bits, $cb->invoke($this->_frame)); + } + + public function secondByteProvider() { + return array( + array(true, 1, '10000001') + , array(false, 1, '00000001') + , array(true, 125, $this->_secondByteMaskedSPL) + ); + } + + /** + * @dataProvider secondByteProvider + */ + public function testIsMaskedReturnsExpectedValue($masked, $payload_length, $bin) { + $this->_frame->addBuffer(static::convert($this->_firstByteFinText)); + $this->_frame->addBuffer(static::convert($bin)); + + $this->assertEquals($masked, $this->_frame->isMasked()); + } + + /** + * @dataProvider UnframeMessageProvider + */ + public function testIsMaskedFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertTrue($this->_frame->isMasked()); + } + + /** + * @dataProvider secondByteProvider + */ + public function testGetPayloadLengthWhenOnlyFirstFrameIsUsed($masked, $payload_length, $bin) { + $this->_frame->addBuffer(static::convert($this->_firstByteFinText)); + $this->_frame->addBuffer(static::convert($bin)); + + $this->assertEquals($payload_length, $this->_frame->getPayloadLength()); + } + + /** + * @dataProvider UnframeMessageProvider + * @todo Not yet testing when second additional payload length descriptor + */ + public function testGetPayloadLengthFromFullMessage($msg, $encoded) { + $this->_frame->addBuffer(base64_decode($encoded)); + $this->assertEquals(strlen($msg), $this->_frame->getPayloadLength()); + } + + protected function generateMask() { + } + + protected function getPacker() { + return function($str) { + $packed = ''; + for ($i = 0, $len = strlen($str); $i < $len; $i++) { + $packed .= pack('C', ord($str[$i])); +// $packed .= pack("c", ord(substr($str, $i, 1))); + } + + return $packed; + }; + } + + public function maskingKeyProvider() { + $packer = $this->getPacker(); + $mask_generator = function() use ($packer) { + $characters = 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*() -=_+`~[]{}\|/,.<>'; + $mask = ''; + + for ($i = 0; $i < 32; $i++) { + $mask .= $characters[mt_rand(0, strlen($characters) -1)]; + } + + return array($mask, $packer($mask)); + }; + + return array( + $mask_generator() + , $mask_generator() + , $mask_generator() + ); + } + + /** + * @dataProvider maskingKeyProvider + * @todo I I wrote the dataProvider incorrectly, skpping for now + */ + public function testGetMaskingKey($mask, $bin) { + return $this->markTestIncomplete("I'm not packing the data properly in the provider, I think"); + + $this->_frame->addBuffer(static::convert($this->_firstByteFinText)); + $this->_frame->addBuffer(static::convert($this->_secondByteMaskedSPL)); + $this->_frame->addBuffer($bin); + + $this->assertEquals($mask, $this->_frame->getMaskingKey()); + } + + /** + * @dataProvider UnframeMessageProvider + * @todo Move this test to bottom as it requires all methods of the class + */ + public function testUnframeFullMessage($unframed, $base_framed) { + $this->_frame->addBuffer(base64_decode($base_framed)); + $this->assertEquals($unframed, $this->_frame->getPayload()); + } + + public static function messageFragmentProvider() { + return array( + array(false, '', '', '', '', '') + ); + } + + /** + * @dataProvider messageFragmentProvider + */ + public function testCheckPiecingTogetherMessage($coalesced, $first_bin, $secnd_bin, $mask, $payload1, $payload2) { + return $this->markTestIncomplete('Ran out of time, had to attend to something else, come finish me!'); + + $this->_frame->addBuffer(static::convert($first_bin)); + $this->_frame->addBuffer(static::convert($second_bin)); + // mask? +// $this->_frame->addBuffer( +// $this->_frame->addBuffer( + + $this->assertEquals($coalesced, $this->_frame->isCoalesced()); + } +} \ No newline at end of file