From 6a67db3efd0604cb16fc25a019b82e5cd21ce576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 15 May 2011 20:57:29 +0200 Subject: [PATCH 01/13] Overhaul WebSocket protocol parsing using makeStatefulDispatcher. Support 0xFF frames, as specified in the WebSocket protocol draft hixie-76, section 5.3. Introduce a separate instance variable controlling the maximum size of a 0xFF frame that the server will accept. --- test_websocket.py | 94 ++++++++++++++++++++++++++++++ websocket.py | 144 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 209 insertions(+), 29 deletions(-) diff --git a/test_websocket.py b/test_websocket.py index ee1855d..14628c6 100644 --- a/test_websocket.py +++ b/test_websocket.py @@ -11,6 +11,7 @@ from websocket import WebSocketHandler, WebSocketFrameDecoder from websocket import WebSocketSite, WebSocketTransport +from websocket import DecodingError from twisted.web.resource import Resource from twisted.web.server import Request, Site @@ -372,6 +373,17 @@ def setUp(self): transport._attachHandler(handler) self.decoder = WebSocketFrameDecoder(request, handler) self.decoder.MAX_LENGTH = 100 + self.decoder.MAX_BINARY_LENGTH = 1000 + + + def assertOneDecodingError(self): + """ + Assert that exactly one L{DecodingError} has been logged and return + that error. + """ + errors = self.flushLoggedErrors(DecodingError) + self.assertEquals(len(errors), 1) + return errors[0] def test_oneFrame(self): @@ -406,6 +418,7 @@ def test_missingNull(self): dropped. """ self.decoder.dataReceived("frame\xff") + self.assertOneDecodingError() self.assertTrue(self.channel.transport.disconnected) @@ -415,6 +428,7 @@ def test_missingNullAfterGoodFrame(self): frame, the connection is dropped. """ self.decoder.dataReceived("\x00frame\xfffoo") + self.assertOneDecodingError() self.assertTrue(self.channel.transport.disconnected) self.assertEquals(self.decoder.handler.frames, ["frame"]) @@ -456,6 +470,86 @@ def test_frameLengthReset(self): self.assertFalse(self.channel.transport.disconnected) + def test_oneBinaryFrame(self): + """ + A binary frame is parsed and ignored, the following text frame is + delivered. + """ + self.decoder.dataReceived("\xff\x0abinarydata\x00text frame\xff") + self.assertEquals(self.decoder.handler.frames, ["text frame"]) + + + def test_multipleBinaryFrames(self): + """ + Text frames intermingled with binary frames are parsed correctly. + """ + tf1, tf2, tf3 = "\x00frame1\xff", "\x00frame2\xff", "\x00frame3\xff" + bf1, bf2, bf3 = "\xff\x01X", "\xff\x1a" + "X" * 0x1a, "\xff\x02AB" + + self.decoder.dataReceived(tf1 + bf1 + bf2 + tf2 + tf3 + bf3) + self.assertEquals(self.decoder.handler.frames, + ["frame1", "frame2", "frame3"]) + + + def test_binaryFrameMultipleLengthBytes(self): + """ + A binary frame can have its length field spread across multiple bytes. + """ + bf = "\xff\x81\x48" + "X" * 200 + tf = "\x00frame\xff" + self.decoder.dataReceived(bf + tf + bf) + self.assertEquals(self.decoder.handler.frames, ["frame"]) + + + def test_binaryAndTextSplitted(self): + """ + Intermingled binary and text frames can be split across several + C{dataReceived} calls. + """ + tf1, tf2 = "\x00text\xff", "\x00other text\xff" + bf1, bf2, bf3 = ("\xff\x01X", "\xff\x81\x48" + "X" * 200, + "\xff\x20" + "X" * 32) + + chunks = [bf1[0], bf1[1:], tf1[:2], tf1[2:] + bf2[:2], bf2[2:-2], + bf2[-2:-1], bf2[1] + tf2[:-1], tf2[-1], bf3] + for c in chunks: + self.decoder.dataReceived(c) + + self.assertEquals(self.decoder.handler.frames, ["text", "other text"]) + self.assertFalse(self.channel.transport.disconnected) + + + def text_maxBinaryLength(self): + """ + If a binary frame's declared length exceeds MAX_BINARY_LENGTH, the + connection is dropped. + """ + self.decoder.dataReceived("\xff\xff\xff\xff\xff\xff") + self.assertTrue(self.channel.transport.disconnected) + + + def test_invalidFrameType(self): + """ + Frame types other than 0x00 and 0xff cause the connection to be + dropped. + """ + ok = "\x00ok\xff" + wrong = "\x05foo\xff" + + self.decoder.dataReceived(ok + wrong + ok) + self.assertEquals(self.decoder.handler.frames, ["ok"]) + error = self.assertOneDecodingError() + self.assertTrue(self.channel.transport.disconnected) + + + def test_emptyFrame(self): + """ + An empty text frame is correctly parsed. + """ + self.decoder.dataReceived("\x00\xff") + self.assertEquals(self.decoder.handler.frames, [""]) + self.assertFalse(self.channel.transport.disconnected) + class WebSocketHandlerTestCase(TestCase): """ diff --git a/websocket.py b/websocket.py index 5cae1d3..7e0de99 100644 --- a/websocket.py +++ b/websocket.py @@ -18,6 +18,8 @@ import struct from twisted.internet import interfaces +from twisted.python import log +from twisted.web._newclient import makeStatefulDispatcher from twisted.web.http import datetimeToString from twisted.web.http import _IdentityTransferDecoder from twisted.web.server import Request, Site, version, unquote @@ -417,15 +419,28 @@ def connectionLost(self, reason): """ +class IncompleteFrame(Exception): + """ + Not enough data to complete a WebSocket frame. + """ + + +class DecodingError(Exception): + """ + The incoming data is not valid WebSocket protocol data. + """ + class WebSocketFrameDecoder(object): """ Decode WebSocket frames and pass them to the attached C{WebSocketHandler} instance. - @ivar MAX_LENGTH: maximum len of the frame allowed, before calling + @ivar MAX_LENGTH: maximum len of a text frame allowed, before calling C{frameLengthExceeded} on the handler. @type MAX_LENGTH: C{int} + @ivar MAX_BINARY_LENGTH: like C{MAX_LENGTH}, but for 0xff type frames + @type MAX_BINARY_LENGTH: C{int} @ivar request: C{Request} instance. @type request: L{twisted.web.server.Request} @ivar handler: L{WebSocketHandler} instance handling the request. @@ -438,13 +453,14 @@ class WebSocketFrameDecoder(object): """ MAX_LENGTH = 16384 - + MAX_BINARY_LENGTH = 2147483648 def __init__(self, request, handler): self.request = request self.handler = handler self._data = [] self._currentFrameLength = 0 + self._state = "FRAME_START" def dataReceived(self, data): """ @@ -455,35 +471,105 @@ def dataReceived(self, data): """ if not data: return - while True: - endIndex = data.find("\xff") - if endIndex != -1: - self._currentFrameLength += endIndex - if self._currentFrameLength > self.MAX_LENGTH: - self.handler.frameLengthExceeded() - break - self._currentFrameLength = 0 - frame = "".join(self._data) + data[:endIndex] - self._data[:] = [] - if frame[0] != "\x00": - self.request.transport.loseConnection() - break - self.handler.frameReceived(frame[1:]) - data = data[endIndex + 1:] - if not data: - break - if data[0] != "\x00": - self.request.transport.loseConnection() - break - else: - self._currentFrameLength += len(data) - if self._currentFrameLength > self.MAX_LENGTH + 1: - self.handler.frameLengthExceeded() - else: - self._data.append(data) + self._data.append(data) + + while self._data: + try: + self.consumeData(self._data[-1]) + except IncompleteFrame: + break + except DecodingError: + log.err() + self.request.transport.loseConnection() break + def consumeData(self, data): + """ + Process the last data chunk received. + After processing is done, L{IncompleteFrame} should be raised or + L{_addRemainingData} should be called. -__all__ = ["WebSocketHandler", "WebSocketSite"] + @param data: last chunk of data received. + @type data: C{str} + """ + consumeData = makeStatefulDispatcher("consumeData", consumeData) + + def _consumeData_FRAME_START(self, data): + self._currentFrameLength = 0 + + if data[0] == "\x00": + self._state = "PARSING_TEXT_FRAME" + elif data[0] == "\xff": + self._state = "PARSING_LENGTH" + else: + raise DecodingError("Invalid frame type 0x%s" % + data[0].encode("hex")) + self._addRemainingData(data[1:]) + + def _consumeData_PARSING_TEXT_FRAME(self, data): + endIndex = data.find("\xff") + if endIndex == -1: + self._currentFrameLength += len(data) + else: + self._currentFrameLength += endIndex + + self._currentFrameLength += endIndex + # check length + 1 to account for the initial frame type byte + if self._currentFrameLength + 1 > self.MAX_LENGTH: + self.handler.frameLengthExceeded() + + if endIndex == -1: + raise IncompleteFrame() + + frame = "".join(self._data[:-1]) + data[:endIndex] + self.handler.frameReceived(frame) + + remainingData = data[endIndex + 1:] + self._addRemainingData(remainingData) + + self._state = "FRAME_START" + + def _consumeData_PARSING_LENGTH(self, data): + current = 0 + available = len(data) + + while current < available: + byte = ord(data[current]) + length, more = byte & 0x7F, bool(byte & 0x80) + + self._currentFrameLength *= 128 + self._currentFrameLength += length + if self._currentFrameLength > self.MAX_BINARY_LENGTH: + self.handler.frameLengthExceeded() + + current += 1 + + if not more: + remainingData = data[current:] + self._addRemainingData(remainingData) + self._state = "PARSING_BINARY_FRAME" + break + else: + raise IncompleteFrame() + + def _consumeData_PARSING_BINARY_FRAME(self, data): + available = len(data) + + if self._currentFrameLength <= available: + remainingData = data[self._currentFrameLength:] + self._addRemainingData(remainingData) + self._state = "FRAME_START" + else: + self._currentFrameLength -= available + self._data[:] = [] + + def _addRemainingData(self, remainingData): + if remainingData: + self._data[:] = [remainingData] + else: + self._data[:] = [] + + +__all__ = ["WebSocketHandler", "WebSocketSite"] From f7fb75c291b13d529095fcbe5ba861a8c0420bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 15 May 2011 21:04:22 +0200 Subject: [PATCH 02/13] Support the client-initiated WebSocket closing handshake. Specified in section 1.4 of hixie-76, the closing handshake can be initiated by either peer. Support closing handshakes initiated by the client, the server does not have the ability to initiate closing handshakes himself. --- test_websocket.py | 11 +++++++++++ websocket.py | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/test_websocket.py b/test_websocket.py index 14628c6..0bd246e 100644 --- a/test_websocket.py +++ b/test_websocket.py @@ -528,6 +528,17 @@ def text_maxBinaryLength(self): self.assertTrue(self.channel.transport.disconnected) + def test_closingHandshake(self): + """ + After receiving the closing handshake, the server sends its own closing + handshake and ignores all future data. + """ + self.decoder.dataReceived("\x00frame\xff\xff\x00random crap") + self.decoder.dataReceived("more random crap, that's discarded") + self.assertEquals(self.decoder.handler.frames, ["frame"]) + self.assertTrue(self.decoder.closing) + + def test_invalidFrameType(self): """ Frame types other than 0x00 and 0xff cause the connection to be diff --git a/websocket.py b/websocket.py index 7e0de99..b77d9aa 100644 --- a/websocket.py +++ b/websocket.py @@ -441,6 +441,8 @@ class WebSocketFrameDecoder(object): @type MAX_LENGTH: C{int} @ivar MAX_BINARY_LENGTH: like C{MAX_LENGTH}, but for 0xff type frames @type MAX_BINARY_LENGTH: C{int} + @ivar closing: a flag set when the closing handshake has been received + @type closing: C{bool} @ivar request: C{Request} instance. @type request: L{twisted.web.server.Request} @ivar handler: L{WebSocketHandler} instance handling the request. @@ -454,10 +456,12 @@ class WebSocketFrameDecoder(object): MAX_LENGTH = 16384 MAX_BINARY_LENGTH = 2147483648 + closing = False def __init__(self, request, handler): self.request = request self.handler = handler + self.closing = False self._data = [] self._currentFrameLength = 0 self._state = "FRAME_START" @@ -469,11 +473,11 @@ def dataReceived(self, data): @param data: data received over the WebSocket connection. @type data: C{str} """ - if not data: + if not data or self.closing: return self._data.append(data) - while self._data: + while self._data and not self.closing: try: self.consumeData(self._data[-1]) except IncompleteFrame: @@ -539,6 +543,10 @@ def _consumeData_PARSING_LENGTH(self, data): byte = ord(data[current]) length, more = byte & 0x7F, bool(byte & 0x80) + if not length: + self._closingHandshake() + raise IncompleteFrame() + self._currentFrameLength *= 128 self._currentFrameLength += length if self._currentFrameLength > self.MAX_BINARY_LENGTH: @@ -571,5 +579,12 @@ def _addRemainingData(self, remainingData): else: self._data[:] = [] + def _closingHandshake(self): + self.closing = True + # send the closing handshake + self.request.transport.write("\xff\x00") + # discard all buffered data + self._data[:] = [] + __all__ = ["WebSocketHandler", "WebSocketSite"] From 752241f6ce9ed6bdb8dd27c56783d498b9e671cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 05:21:01 +0200 Subject: [PATCH 03/13] Add support for the hybi-10 WebSocket protocol. This protocol version is defined in http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-10 and is incompatible with the currently popular hixie-76 version. Add code to handle the new handshake and data framing. Still missing is support for fragmented frames and unit tests. There is probably a couple of bugs hiding in there, but it was successfully tested with Chromium 15.0.839.0, which implements hybi-10. --- websocket.py | 319 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 312 insertions(+), 7 deletions(-) diff --git a/websocket.py b/websocket.py index b77d9aa..3227bfc 100644 --- a/websocket.py +++ b/websocket.py @@ -14,7 +14,9 @@ @since: 10.1 """ -from hashlib import md5 +import base64 +from hashlib import md5, sha1 +import itertools import struct from twisted.internet import interfaces @@ -28,18 +30,34 @@ _ascii_numbers = frozenset(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) + +(OPCODE_CONT, OPCODE_TEXT, OPCODE_BINARY, + OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG) = (0x0, 0x1, 0x2, 0x8, 0x9, 0xA) + + +ALL_OPCODES = (OPCODE_CONT, OPCODE_TEXT, OPCODE_BINARY, + OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG) + + class WebSocketRequest(Request): """ A general purpose L{Request} supporting connection upgrade for WebSocket. """ + ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + def process(self): - if (self.requestHeaders.getRawHeaders("Upgrade") == ["WebSocket"] and - self.requestHeaders.getRawHeaders("Connection") == ["Upgrade"]): - return self.processWebSocket() - else: + connection = self.requestHeaders.getRawHeaders("Connection", [None])[0] + upgrade = self.requestHeaders.getRawHeaders("Upgrade", [None])[0] + + if connection != "Upgrade": return Request.process(self) + if upgrade not in ("WebSocket", "websocket"): + return Request.process(self) + + return self.processWebSocket() + def processWebSocket(self): """ Process a specific web socket request. @@ -217,6 +235,60 @@ def finish(): protocolHeader = None return originHeaders[0], hostHeaders[0], protocolHeader, handler + def _getOneHeader(self, name): + headers = self.requestHeaders.getRawHeaders(name) + if not headers or len(headers) > 1: + return None + return headers[0] + + def _clientHandshakeHybi(self): + """ + Initial handshake, as defined in hybi-10. + + If the client is not following the hybi-10 protocol or is requesting a + version that's lower than what hybi-10 describes, the connection will + be closed. + + Otherwise the appropriate transport and content decoders will be + plugged in and the connection will be estabilished. + """ + version = self._getOneHeader("Sec-WebSocket-Version") + # we only speak version 8 of the protocol + if version != "8": + self.setResponseCode(426, "Upgrade Required") + self.setHeader("Sec-WebSocket-Version", "8") + return self.finish() + + key = self._getOneHeader("Sec-WebSocket-Key") + if not key: + self.setResponseCode(400, "Bad Request") + return self.finish() + + handlerFactory = self.site.handlers.get(self.uri) + if not handlerFactory: + self.setResponseCode(404, "Not Found") + return self.finish() + + transport = WebSocketHybiTransport(self) + handler = handlerFactory(transport) + transport._attachHandler(handler) + + accept = base64.b64encode(sha1(key + self.ACCEPT_GUID).digest()) + self.startedWriting = True + handshake = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Accept: %s" % accept] + + for header in handshake: + self.write("%s\r\n" % header) + + self.write("\r\n") + self.channel.setRawMode() + self.channel._transferDecoder = WebSocketHybiFrameDecoder( + self, handler) + handler.transport._connectionMade() def renderWebSocket(self): """ @@ -226,6 +298,10 @@ def renderWebSocket(self): connection will be closed. Otherwise, the response to the handshake is sent and a C{WebSocketHandler} is created to handle the request. """ + # check for hybi handshake requests + if self.requestHeaders.hasHeader("Sec-WebSocket-Version"): + return self._clientHandshakeHybi() + # check for post-75 handshake requests isSecHandshake = self.requestHeaders.getRawHeaders("Sec-WebSocket-Key1", []) if isSecHandshake: @@ -257,8 +333,7 @@ def renderWebSocket(self): self.write("\r\n") self.channel.setRawMode() - # XXX we probably don't want to set _transferDecoder - self.channel._transferDecoder = WebSocketFrameDecoder( + self.channel._transferDecoder = WebSocketHybiFrameDecoder( self, handler) handler.transport._connectionMade() return @@ -373,6 +448,66 @@ def loseConnection(self): del self._request del self._handler + +class WebSocketHybiTransport(WebSocketTransport): + """ + A WebSocket transport that speaks the hybi-10 protocol. The L{ITransport} + methods are set up to send Text frames containing the payload. To have + finer-grained control over the type of frame being sent, the transport + provides a L{sendFrame} method. + """ + def write(self, frame): + """ + Treat the given frame as a text frame and send it to the client. + + @param frame: a I{UTF-8} encoded C{str} to send to the client. + @type frame: C{str} + """ + self.sendFrame(OPCODE_TEXT, frame) + + def writeSequence(self, frames): + """ + Send a sequence of text frames to the connected client. + """ + for frame in frames: + self.sendFrame(OPCODE_TEXT, frame) + + def sendFrame(self, opcode, payload): + """ + Send a frame with the given opcode and payload to the client. The frame + will never be sent fragmented. + + @param opcode: the opcode as defined in hybi-10 + @type opcode: C{int} + @param payload: the frame's payload + @type payload: C{str} + """ + if opcode not in ALL_OPCODES: + raise ValueError("Invalid opcode 0x%X" % opcode) + + length = len(payload) + + # there's always the header and at least one length field + spec = ">BB" + data = [0x80 | opcode] + + # there's no masking, so the high bit of the first byte of length is + # always 0 + if 125 < length <= 65535: + # add a 16-bit int to the spec and append 126 value, which means + # "interpret the next two bytes" + spec += "H" + data.append(126) + elif length > 65535: + # same for even longer frames + spec += "Q" + data.append(127) + + data.append(length) + header = struct.pack(spec, *data) + self._request.write(header + payload) + + class WebSocketHandler(object): """ Base class for handling WebSocket connections. It mainly provides a @@ -399,6 +534,35 @@ def frameReceived(self, frame): """ + def binaryFrameReceived(self, data): + """ + Called when a binary is received via the hybi protocol. + + @param data: a binary C{str} sent by the client. + @type data: C{str} + """ + + + def pongReceived(self, data): + """ + Called when a pong control message is received via the hybi protocol. + + @param data: the payload sent by the client. + @type data: C{str} + """ + + + def closeReceived(self, code, msg): + """ + Called when a close control message is received via the hybi protocol. + + @param code: the status code of the close message, if present + @type code: C{int} or C{None} + @param msg: the I{UTF-8} encoded message sent by the client, if present + @type msg: C{str} or C{None} + """ + + def frameLengthExceeded(self): """ Called when too big a frame is received. The default behavior is to @@ -587,4 +751,145 @@ def _closingHandshake(self): self._data[:] = [] +class WebSocketHybiFrameDecoder(WebSocketFrameDecoder): + + def __init__(self, request, handler): + WebSocketFrameDecoder.__init__(self, request, handler) + self._opcode = None + self._state = "HYBI_FRAME_START" + + def _consumeData_HYBI_FRAME_START(self, data): + byte = ord(data[0]) + fin, reserved, opcode = byte & 0x80, byte & 0x70, byte & 0x0F + + if not fin: + raise DecodingError("Fragmentation not supported yet") + + if reserved: + raise DecodingError("Reserved bits set: 0x%02X" % byte) + + if opcode == OPCODE_CONT: + raise DecodingError("Fragmentation not supported yet") + + if opcode not in ALL_OPCODES: + raise DecodingError("Invalid opcode 0x%X" % opcode) + + self._opcode = opcode + self._state = "HYBI_PARSING_LENGTH" + self._addRemainingData(data[1:]) + + def _consumeData_HYBI_PARSING_LENGTH(self, data): + byte = ord(data[0]) + masked, length = byte & 0x80, byte & 0x7F + + if not masked: + raise DecodingError("Unmasked frame received") + + if length < 126: + self._currentFrameLength = length + self._state = "HYBI_MASKING_KEY" + elif length == 126: + self._state = "HYBI_PARSING_LENGTH_2" + elif length == 127: + self._state = "HYBI_PARSING_LENGTH_3" + + self._addRemainingData(data[1:]) + + def _consumeData_HYBI_PARSING_LENGTH_2(self, data): + self._parse_length_spec(2, ">H") + + def _consumeData_HYBI_PARSING_LENGTH_3(self, data): + self._parse_length_spec(8, ">Q", 0x7fffffffffffffff) + + def _parse_length_spec(self, needed, spec, limit=None): + # if the accumulated data is not long enough to parse out the length, + # keep on accumulating + if sum(map(len, self._data)) < needed: + raise IncompleteFrame() + + data = "".join(self._data) + self._currentFrameLength = struct.unpack(spec, data[:needed])[0] + if limit and self._currentFrameLength > limit: + raise DecodingError( + "Frame length exceeded: %r" % self._currentFrameLength) + self._addRemainingData(data[needed:]) + + if self._opcode == OPCODE_TEXT: + if self._currentFrameLength > self.MAX_LENGTH: + self.handler.frameLengthExceeded() + elif self._opcode == OPCODE_BINARY: + if self._currentFrameLength > self.MAX_BINARY_LENGTH: + self.handler.frameLengthExceeded() + + self._state = "HYBI_MASKING_KEY" + + def _consumeData_HYBI_MASKING_KEY(self, data): + if sum(map(len, self._data)) < 4: + raise IncompleteFrame() + + data = "".join(self._data) + self._maskingKey = struct.unpack(">4B", data[:4]) + self._addRemainingData(data[4:]) + self._state = "HYBI_PAYLOAD" + + def _consumeData_HYBI_PAYLOAD(self, data): + available = len(data) + + if self._currentFrameLength > available: + self._currentFrameLength -= available + raise IncompleteFrame() + + frame = "".join(self._data[:-1]) + data[:self._currentFrameLength] + + # unmask the frame + bufferedPayload = itertools.chain(*self._data[:-1]) + restOfPayload = data[:self._currentFrameLength] + allData = itertools.chain(bufferedPayload, restOfPayload) + + key = itertools.cycle(self._maskingKey) + + def xor(c, k): + return chr(ord(c) ^ k) + unmasked = itertools.imap(xor, allData, key) + + frame = "".join(unmasked) + + if self._opcode == OPCODE_TEXT: + # assume it's valid UTF-8 and let the client handle the rest + self.handler.frameReceived(frame) + elif self._opcode == OPCODE_BINARY: + self.handler.binaryFrameReceived(frame) + elif self._opcode == OPCODE_PING: + self.transport.sendFrame(OPCODE_PONG, frame) + elif self._opcode == OPCODE_PONG: + self.handler.pongReceived(frame) + + self._state = "HYBI_FRAME_START" + remainingData = data[self._currentFrameLength:] + self._addRemainingData(remainingData) + + # if the opcode was CLOSE, initiate connection closing + if self._opcode == OPCODE_CLOSE: + self._hybiClose(frame) + + def _hybiClose(self, frame): + self.closing = True + + # try to parse out the status code and message + if len(frame) > 1: + code = struct.unpack(">H", frame[:2])[0] + msg = frame[2:] + else: + code, msg = None, None + # let the handler know + self.handler.closeReceived(code, msg) + + # send the closing handshake + self.transport.sendFrame(OPCODE_CLOSE, "") + + # discard all buffered data and lose connection + self._data[:] = [] + self.transport.loseConnection() + + __all__ = ["WebSocketHandler", "WebSocketSite"] From f66de480d07e1f6fa818955e016cc8be47041321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 17:35:29 +0200 Subject: [PATCH 04/13] Support fragmentation in the hybi-10 framing protocol. --- websocket.py | 70 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/websocket.py b/websocket.py index 3227bfc..f701b93 100644 --- a/websocket.py +++ b/websocket.py @@ -39,6 +39,10 @@ OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG) +CONTROL_OPCODES = (OPCODE_CLOSE, OPCODE_PING, OPCODE_PONG) +DATA_OPCODES = (OPCODE_TEXT, OPCODE_BINARY) + + class WebSocketRequest(Request): """ A general purpose L{Request} supporting connection upgrade for WebSocket. @@ -756,25 +760,53 @@ class WebSocketHybiFrameDecoder(WebSocketFrameDecoder): def __init__(self, request, handler): WebSocketFrameDecoder.__init__(self, request, handler) self._opcode = None + self._fragment_opcode = None + self._fragments = [] self._state = "HYBI_FRAME_START" def _consumeData_HYBI_FRAME_START(self, data): + self._opcode = None + byte = ord(data[0]) fin, reserved, opcode = byte & 0x80, byte & 0x70, byte & 0x0F - if not fin: - raise DecodingError("Fragmentation not supported yet") - if reserved: raise DecodingError("Reserved bits set: 0x%02X" % byte) - if opcode == OPCODE_CONT: - raise DecodingError("Fragmentation not supported yet") - if opcode not in ALL_OPCODES: raise DecodingError("Invalid opcode 0x%X" % opcode) - self._opcode = opcode + if not fin: + # part of a fragmented frame + if not self._fragment_opcode: + # first of the fragmented frames, which determines the opcode + if opcode not in DATA_OPCODES: + raise DecodingError( + "Fragmented frame with invalid opcode 0x%X" % opcode) + # save the opcode for later use + self._fragment_opcode = opcode + else: + # already reading a fragmet, and this is a fragmented frame, so + # it has to use the continuation opcode + if opcode != OPCODE_CONT: + raise DecodingError( + "Continuation frame with invalid opcode 0x%X" % opcode) + else: + # self-contained frame or last of the fragmented frames + if self._fragment_opcode: + # a fragmented frame is pending, so this can only be the end of + # it or a control message + if opcode not in CONTROL_OPCODES and opcode != OPCODE_CONT: + raise DecodingError( + "Final frame with invalid opcode 0x%X" % opcode) + else: + # no fragmented frames pending, so this cannot be a + # continuation frame + if opcode == OPCODE_CONT: + raise DecodingError( + "Final frame with invalid opcode 0x%X" % opcode) + self._opcode = opcode + self._state = "HYBI_PARSING_LENGTH" self._addRemainingData(data[1:]) @@ -814,13 +846,6 @@ def _parse_length_spec(self, needed, spec, limit=None): "Frame length exceeded: %r" % self._currentFrameLength) self._addRemainingData(data[needed:]) - if self._opcode == OPCODE_TEXT: - if self._currentFrameLength > self.MAX_LENGTH: - self.handler.frameLengthExceeded() - elif self._opcode == OPCODE_BINARY: - if self._currentFrameLength > self.MAX_BINARY_LENGTH: - self.handler.frameLengthExceeded() - self._state = "HYBI_MASKING_KEY" def _consumeData_HYBI_MASKING_KEY(self, data): @@ -854,10 +879,27 @@ def xor(c, k): frame = "".join(unmasked) + # if it's part of a fragmented frame, store the payload + if self._fragment_opcode: + self._fragments.append(frame) + + # if it's the last of the fragmented frames, replace the opcode with + # the original one from the fragment and the frame with the accumulated + # payload + if self._opcode == OPCODE_CONT: + self._opcode = self._fragment_opcode + frame = "".join(self._fragments) + self._fragment_opcode = None + self._fragments[:] = [] + if self._opcode == OPCODE_TEXT: # assume it's valid UTF-8 and let the client handle the rest + if len(frame) > self.MAX_LENGTH: + self.handler.frameLengthExceeded() self.handler.frameReceived(frame) elif self._opcode == OPCODE_BINARY: + if len(frame) > self.MAX_BINARY_LENGTH: + self.handler.frameLengthExceeded() self.handler.binaryFrameReceived(frame) elif self._opcode == OPCODE_PING: self.transport.sendFrame(OPCODE_PONG, frame) From fbf3a21e78f3917610e2be68f8d29a5ffb71626a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 17:41:20 +0200 Subject: [PATCH 05/13] Support sending fragmented payloads with WebSocketHybiTransport. --- websocket.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/websocket.py b/websocket.py index f701b93..1c96e05 100644 --- a/websocket.py +++ b/websocket.py @@ -476,15 +476,23 @@ def writeSequence(self, frames): for frame in frames: self.sendFrame(OPCODE_TEXT, frame) - def sendFrame(self, opcode, payload): + def sendFrame(self, opcode, payload, fragmented=False): """ - Send a frame with the given opcode and payload to the client. The frame - will never be sent fragmented. + Send a frame with the given opcode and payload to the client. If the + L{fragmented} parameter is set, the message frame will contain a flag + saying it's part of a fragmented payload, by default data is sent as a + self-contained frame. Note that if you use fragmentation support, it is + up to you to correctly set the first frame's opcode and then use + L{OPCODE_CONT} on the following continuation frames. + + Payloads sent using this method are never masked. @param opcode: the opcode as defined in hybi-10 @type opcode: C{int} @param payload: the frame's payload @type payload: C{str} + @param fragmented: should the frame be marked as part of a fragmented payload + @type fragmented: C{bool} """ if opcode not in ALL_OPCODES: raise ValueError("Invalid opcode 0x%X" % opcode) @@ -493,7 +501,11 @@ def sendFrame(self, opcode, payload): # there's always the header and at least one length field spec = ">BB" - data = [0x80 | opcode] + if fragmented: + header = 0x00 + else: + header = 0x80 + data = [header | opcode] # there's no masking, so the high bit of the first byte of length is # always 0 From 288e59fca3abc90a850a9ad0175769b41f6f499c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 17:49:38 +0200 Subject: [PATCH 06/13] Fix decoding of pre-hixie-76 protocol version. Commit 752241f6ce9ed6bdb8dd27c56783d498b9e671cb accditentally ended up hooking the hybi-10 protocol decoder to requests using the pre-hixie-75 handshake. --- websocket.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/websocket.py b/websocket.py index 1c96e05..1a4b351 100644 --- a/websocket.py +++ b/websocket.py @@ -337,7 +337,8 @@ def renderWebSocket(self): self.write("\r\n") self.channel.setRawMode() - self.channel._transferDecoder = WebSocketHybiFrameDecoder( + # XXX we probably don't want to set _transferDecoder + self.channel._transferDecoder = WebSocketFrameDecoder( self, handler) handler.transport._connectionMade() return From 61ba48b9c67a7e89ef74a6a0444117d482f2c7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:00:11 +0200 Subject: [PATCH 07/13] Fix a test for exceeding maximum binary frame length in hixie-76 mode. --- test_websocket.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_websocket.py b/test_websocket.py index 0bd246e..9877d16 100644 --- a/test_websocket.py +++ b/test_websocket.py @@ -519,12 +519,12 @@ def test_binaryAndTextSplitted(self): self.assertFalse(self.channel.transport.disconnected) - def text_maxBinaryLength(self): + def test_maxBinaryLength(self): """ If a binary frame's declared length exceeds MAX_BINARY_LENGTH, the connection is dropped. """ - self.decoder.dataReceived("\xff\xff\xff\xff\xff\xff") + self.decoder.dataReceived("\xff\xff\xff\xff\xff\x01") self.assertTrue(self.channel.transport.disconnected) From 846bcddaa624ff09a67464378e27686faaf083c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:07:26 +0200 Subject: [PATCH 08/13] Only complain about binary frame length after reading all of it. Otherwise the frameLengthExceeded handler can be called multiple times while decoding the length. --- websocket.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/websocket.py b/websocket.py index b77d9aa..8908c37 100644 --- a/websocket.py +++ b/websocket.py @@ -549,12 +549,13 @@ def _consumeData_PARSING_LENGTH(self, data): self._currentFrameLength *= 128 self._currentFrameLength += length - if self._currentFrameLength > self.MAX_BINARY_LENGTH: - self.handler.frameLengthExceeded() current += 1 if not more: + if self._currentFrameLength > self.MAX_BINARY_LENGTH: + self.handler.frameLengthExceeded() + remainingData = data[current:] self._addRemainingData(remainingData) self._state = "PARSING_BINARY_FRAME" From cc549ab4e8027e3212aa144fedc8b24d2e640fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:15:07 +0200 Subject: [PATCH 09/13] Factor out a method that completes frame reception. In passing fix a few bugs where opcodes were mismatched and frame fragments were lost. --- websocket.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/websocket.py b/websocket.py index 1648a76..526b9f5 100644 --- a/websocket.py +++ b/websocket.py @@ -892,9 +892,13 @@ def xor(c, k): unmasked = itertools.imap(xor, allData, key) frame = "".join(unmasked) + remainingData = data[self._currentFrameLength:] + + self._frameCompleted(frame, remainingData) + def _frameCompleted(self, frame, remainingData): # if it's part of a fragmented frame, store the payload - if self._fragment_opcode: + if self._opcode is None: self._fragments.append(frame) # if it's the last of the fragmented frames, replace the opcode with @@ -902,6 +906,7 @@ def xor(c, k): # payload if self._opcode == OPCODE_CONT: self._opcode = self._fragment_opcode + self._fragments.append(frame) frame = "".join(self._fragments) self._fragment_opcode = None self._fragments[:] = [] @@ -921,7 +926,6 @@ def xor(c, k): self.handler.pongReceived(frame) self._state = "HYBI_FRAME_START" - remainingData = data[self._currentFrameLength:] self._addRemainingData(remainingData) # if the opcode was CLOSE, initiate connection closing From 903d9bcd5befd890ab0ea36a453aa18f64ef8140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:16:02 +0200 Subject: [PATCH 10/13] Fix reference errors when disconnecting the client. The decoder does not have a reference to the transport, it has to go through the handler. --- websocket.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/websocket.py b/websocket.py index 526b9f5..e833308 100644 --- a/websocket.py +++ b/websocket.py @@ -921,7 +921,7 @@ def _frameCompleted(self, frame, remainingData): self.handler.frameLengthExceeded() self.handler.binaryFrameReceived(frame) elif self._opcode == OPCODE_PING: - self.transport.sendFrame(OPCODE_PONG, frame) + self.handler.transport.sendFrame(OPCODE_PONG, frame) elif self._opcode == OPCODE_PONG: self.handler.pongReceived(frame) @@ -945,11 +945,11 @@ def _hybiClose(self, frame): self.handler.closeReceived(code, msg) # send the closing handshake - self.transport.sendFrame(OPCODE_CLOSE, "") + self.handler.transport.sendFrame(OPCODE_CLOSE, "") # discard all buffered data and lose connection self._data[:] = [] - self.transport.loseConnection() + self.handler.transport.loseConnection() __all__ = ["WebSocketHandler", "WebSocketSite"] From e2235099553e2af2e8910f0eb454409dde43570e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:17:09 +0200 Subject: [PATCH 11/13] Fix misparsing of empty frames. Empty frames were putting the decoder in the PAYLOAD state, from which it was never recovering because no payload was ever received. --- websocket.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/websocket.py b/websocket.py index e833308..2fae968 100644 --- a/websocket.py +++ b/websocket.py @@ -869,7 +869,13 @@ def _consumeData_HYBI_MASKING_KEY(self, data): data = "".join(self._data) self._maskingKey = struct.unpack(">4B", data[:4]) self._addRemainingData(data[4:]) - self._state = "HYBI_PAYLOAD" + + if self._currentFrameLength: + self._state = "HYBI_PAYLOAD" + else: + # there will be no payload, notify the handler of an empty frame + # and continue + self._frameCompleted("", data[4:]) def _consumeData_HYBI_PAYLOAD(self, data): available = len(data) From 29ec4bd68e9ef670ca69143715130cc4a74f427b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:18:19 +0200 Subject: [PATCH 12/13] Add tests for the hybi-10 decoder and request handler. --- test_websocket.py | 338 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 336 insertions(+), 2 deletions(-) diff --git a/test_websocket.py b/test_websocket.py index 9877d16..5235ea5 100644 --- a/test_websocket.py +++ b/test_websocket.py @@ -4,14 +4,17 @@ """ Tests for L{twisted.web.websocket}. """ +import base64 +from hashlib import sha1 from twisted.internet.main import CONNECTION_DONE from twisted.internet.error import ConnectionDone from twisted.python.failure import Failure from websocket import WebSocketHandler, WebSocketFrameDecoder -from websocket import WebSocketSite, WebSocketTransport -from websocket import DecodingError +from websocket import WebSocketHybiFrameDecoder +from websocket import WebSocketSite, WebSocketTransport, WebSocketHybiTransport +from websocket import DecodingError, OPCODE_PING, OPCODE_TEXT from twisted.web.resource import Resource from twisted.web.server import Request, Site @@ -45,6 +48,9 @@ class TestHandler(WebSocketHandler): def __init__(self, request): WebSocketHandler.__init__(self, request) self.frames = [] + self.binaryFrames = [] + self.pongs = [] + self.closes = [] self.lostReason = None @@ -52,6 +58,18 @@ def frameReceived(self, frame): self.frames.append(frame) + def binaryFrameReceived(self, frame): + self.binaryFrames.append(frame) + + + def pongReceived(self, data): + self.pongs.append(data) + + + def closeReceived(self, code, msg): + self.closes.append((code, msg)) + + def connectionLost(self, reason): self.lostReason = reason @@ -82,6 +100,8 @@ def renderRequest(self, headers=None, url="/test", ssl=False, channel.transport = channel.SSL() channel.site = self.site request = self.site.requestFactory(channel, queued) + # store the reference to the request, so the tests can access it + channel.request = request for k, v in headers: request.requestHeaders.addRawHeader(k, v) request.gotLength(0) @@ -359,6 +379,91 @@ def test_addHandlerWithoutSlash(self): ValueError, self.site.addHandler, "test", TestHandler) + def test_render_handShakeHybi(self): + """ + Test a hybi-10 handshake. + """ + # the key is a base64 encoded 16-bit integer, here chosen to be 14 + key = "AA4=" + headers = [ + ("Upgrade", "websocket"), ("Connection", "Upgrade"), + ("Host", "localhost"), ("Origin", "http://localhost/"), + ("Sec-WebSocket-Version", "8"), ("Sec-WebSocket-Key", key)] + channel = self.renderRequest(headers=headers) + + self.assertTrue(channel.raw) + + result = channel.transport.written.getvalue() + headers, response = result.split('\r\n\r\n') + + guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + accept = base64.b64encode(sha1(key + guid).digest()) + self.assertEquals( + headers, + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s" % accept) + + self.assertFalse(channel.transport.disconnected) + self.assertFalse(channel.request.finished) + + + def test_hybiWrongVersion(self): + """ + A handshake that requests an unsupported version of the protocol + results in HTTP 426. + """ + key = "AA4=" + headers = [ + ("Upgrade", "websocket"), ("Connection", "Upgrade"), + ("Host", "localhost"), ("Origin", "http://localhost/"), + ("Sec-WebSocket-Version", "9"), ("Sec-WebSocket-Key", key)] + channel = self.renderRequest(headers=headers) + + result = channel.transport.written.getvalue() + + self.assertIn("HTTP/1.1 426", result) + # Twisted canonicalizes header names (see + # http_headers.Headers._canonicalNameCaps), so it's not + # Sec-WebSocket-Version, but Sec-Websocket-Version, but clients + # understand it anyway + self.assertIn("Sec-Websocket-Version: 8", result) + self.assertTrue(channel.request.finished) + + + def test_hybiNoKey(self): + """ + A handshake without a websocket key results in HTTP 400. + """ + headers = [ + ("Upgrade", "websocket"), ("Connection", "Upgrade"), + ("Host", "localhost"), ("Origin", "http://localhost/"), + ("Sec-WebSocket-Version", "8")] + channel = self.renderRequest(headers=headers) + + result = channel.transport.written.getvalue() + + self.assertIn("HTTP/1.1 400", result) + self.assertTrue(channel.request.finished) + + + def test_hybiNotFound(self): + """ + A request for an unknown endpoint results in HTTP 404. + """ + key = "AA4=" + headers = [ + ("Upgrade", "websocket"), ("Connection", "Upgrade"), + ("Host", "localhost"), ("Origin", "http://localhost/"), + ("Sec-WebSocket-Version", "8"), ("Sec-WebSocket-Key", key)] + channel = self.renderRequest(headers=headers, url="/foo") + + result = channel.transport.written.getvalue() + + self.assertIn("HTTP/1.1 404", result) + self.assertTrue(channel.request.finished) + class WebSocketFrameDecoderTestCase(TestCase): """ @@ -562,6 +667,172 @@ def test_emptyFrame(self): self.assertFalse(self.channel.transport.disconnected) +class WebSocketHybiFrameDecoderTestCase(TestCase): + """ + Test for C{WebSocketHybiFrameDecoder}. + """ + + def setUp(self): + self.channel = DummyChannel() + request = Request(self.channel, False) + transport = WebSocketHybiTransport(request) + handler = TestHandler(transport) + transport._attachHandler(handler) + self.decoder = WebSocketHybiFrameDecoder(request, handler) + self.decoder.MAX_LENGTH = 100 + self.decoder.MAX_BINARY_LENGTH = 1000 + # taken straight from the IETF draft, masking added where appropriate + self.hello = "\x81\x85\x37\xfa\x21\x3d\x7f\x9f\x4d\x51\x58" + self.frag_hello = ("\x01\x83\x12\x21\x65\x23\x5a\x44\x09", + "\x80\x82\x63\x34\xf1\x00\x0f\x5b") + self.binary_orig = "\x3f" * 256 + self.binary = ("\x82\xfe\x01\x00\x12\x6d\xa6\x23" + + "\x2d\x52\x99\x1c" * 64) + self.ping = "\x89\x85\x56\x23\x88\x23\x1e\x46\xe4\x4f\x39" + self.pong = "\x8a\x85\xde\x41\x0f\x34\x96\x24\x63\x58\xb1" + self.pong_unmasked = "\x8a\x05\x48\x65\x6c\x6c\x6f" + # code 1000, message "Normal Closure" + self.close = ("\x88\x90\x34\x23\x87\xde\x37\xcb\xc9\xb1\x46" + "\x4e\xe6\xb2\x14\x60\xeb\xb1\x47\x56\xf5\xbb") + self.empty_unmasked_close = "\x88\x00" + self.empty_text = "\x81\x80\x00\x01\x02\x03" + self.cont_empty_text = "\x00\x80\x00\x01\x02\x03" + + + def assertOneDecodingError(self): + """ + Assert that exactly one L{DecodingError} has been logged and return + that error. + """ + errors = self.flushLoggedErrors(DecodingError) + self.assertEquals(len(errors), 1) + return errors[0] + + + def test_oneTextFrame(self): + """ + We can send one frame handled with one C{dataReceived} call. + """ + self.decoder.dataReceived(self.hello) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + + + def test_chunkedTextFrame(self): + """ + We can send one text frame handled with multiple C{dataReceived} calls. + """ + # taken straight from the IETF draft + for part in (self.hello[:1], self.hello[1:3], + self.hello[3:7], self.hello[7:]): + self.decoder.dataReceived(part) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + + + def test_fragmentedTextFrame(self): + """ + We can send a fragmented frame handled with one C{dataReceived} call. + """ + self.decoder.dataReceived("".join(self.frag_hello)) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + + + def test_chunkedfragmentedTextFrame(self): + """ + We can send a fragmented text frame handled with multiple + C{dataReceived} calls. + """ + # taken straight from the IETF draft + for part in (self.frag_hello[0][:3], self.frag_hello[0][3:]): + self.decoder.dataReceived(part) + for part in (self.frag_hello[1][:1], self.frag_hello[1][1:]): + self.decoder.dataReceived(part) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + + + def test_twoFrames(self): + """ + We can send two frames together and they will be correctly parsed. + """ + self.decoder.dataReceived("".join(self.frag_hello) + self.hello) + self.assertEquals(self.decoder.handler.frames, ["Hello"] * 2) + + + def test_controlInterleaved(self): + """ + A control message (in this case a pong) can appear between the + fragmented frames. + """ + data = self.frag_hello[0] + self.pong + self.frag_hello[1] + for part in data[:2], data[2:7], data[7:8], data[8:14], data[14:]: + self.decoder.dataReceived(part) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + self.assertEquals(self.decoder.handler.pongs, ["Hello"]) + + + def test_binaryFrame(self): + """ + We can send a binary frame that uses a longer length field. + """ + data = self.binary + for part in data[:3], data[3:4], data[4:]: + self.decoder.dataReceived(part) + self.assertEquals(self.decoder.handler.binaryFrames, + [self.binary_orig]) + + + def test_pingInterleaved(self): + """ + We can get a ping frame in the middle of a fragmented frame and we'll + correctly send a pong resonse. + """ + data = self.frag_hello[0] + self.ping + self.frag_hello[1] + for part in data[:12], data[12:16], data[16:]: + self.decoder.dataReceived(part) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + + result = self.channel.transport.written.getvalue() + headers, response = result.split('\r\n\r\n') + + self.assertEquals(response, self.pong_unmasked) + + + def test_close(self): + """ + A close frame causes the remaining data to be discarded and the + connection to be closed. + """ + self.decoder.dataReceived(self.hello + self.close + "crap" * 20) + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + + result = self.channel.transport.written.getvalue() + headers, response = result.split('\r\n\r\n') + + self.assertEquals(response, self.empty_unmasked_close) + self.assertTrue(self.channel.transport.disconnected) + + + def test_emptyFrame(self): + """ + An empty text frame is correctly parsed. + """ + self.decoder.dataReceived(self.empty_text) + self.assertEquals(self.decoder.handler.frames, [""]) + + + def test_emptyFrameInterleaved(self): + """ + An empty fragmented frame and a interleaved pong message are received + and parsed. + """ + data = (self.frag_hello[0] + self.cont_empty_text + + self.pong + self.frag_hello[1]) + for part in data[:1], data[1:8], data[8:17], data[17:]: + self.decoder.dataReceived(part) + + self.assertEquals(self.decoder.handler.frames, ["Hello"]) + self.assertEquals(self.decoder.handler.pongs, ["Hello"]) + + class WebSocketHandlerTestCase(TestCase): """ Tests for L{WebSocketHandler}. @@ -605,3 +876,66 @@ def test_connectionLost(self): """ self.request.connectionLost(Failure(CONNECTION_DONE)) self.handler.lostReason.trap(ConnectionDone) + + +class WebSocketHybiHandlerTestCase(TestCase): + """ + Tests for L{WebSocketHandler} using the hybi-10 protocol. + """ + + def setUp(self): + self.channel = DummyChannel() + self.request = request = Request(self.channel, False) + # Simulate request handling + request.startedWriting = True + transport = WebSocketHybiTransport(request) + self.handler = TestHandler(transport) + transport._attachHandler(self.handler) + + + def test_write(self): + """ + L{WebSocketHybiTransport.write} wraps the data in a text frame and + writes it to the request. + """ + self.handler.transport.write("Hello") + self.handler.transport.write("World") + self.assertEquals( + self.channel.transport.written.getvalue(), + "\x81\x05\x48\x65\x6c\x6c\x6f" + "\x81\x05\x57\x6f\x72\x6c\x64") + self.assertFalse(self.channel.transport.disconnected) + + + def test_sendFrame(self): + """ + L{WebSocketHybiTransport.sendFrame} creates an unmasked hybi-10 frame + and writes it to the request + """ + self.handler.transport.sendFrame(OPCODE_PING, "ping") + self.assertEquals( + self.channel.transport.written.getvalue(), + "\x89\x04\x70\x69\x6e\x67") + self.assertFalse(self.channel.transport.disconnected) + + + def test_sendLongFrame(self): + """ + Sending a frame with a payload longer than 125 bytes results in a + longer length field written to the request. + """ + self.handler.transport.sendFrame(OPCODE_TEXT, "crap" * 20000) + self.assertEquals( + self.channel.transport.written.getvalue(), + "\x81\x7f\x00\x00\x00\x00\x00\x01\x38\x80" + "crap" * 20000) + self.assertFalse(self.channel.transport.disconnected) + + + def test_sendFragmentedFrame(self): + """ + Sending a frame with the fragmented flag makes the correct flag unset. + """ + self.handler.transport.sendFrame(OPCODE_TEXT, "Hello", fragmented=True) + self.assertEquals( + self.channel.transport.written.getvalue(), + "\x01\x05\x48\x65\x6c\x6c\x6f") + self.assertFalse(self.channel.transport.disconnected) From e048a6c319782a66d699fdcd74478554b05de53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Urba=C5=84ski?= Date: Sun, 31 Jul 2011 21:37:18 +0200 Subject: [PATCH 13/13] Check the exact close message received in the tests. --- test_websocket.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test_websocket.py b/test_websocket.py index 5235ea5..a049da3 100644 --- a/test_websocket.py +++ b/test_websocket.py @@ -803,6 +803,8 @@ def test_close(self): """ self.decoder.dataReceived(self.hello + self.close + "crap" * 20) self.assertEquals(self.decoder.handler.frames, ["Hello"]) + self.assertEquals(self.decoder.handler.closes, + [(1000, "Normal Closure")]) result = self.channel.transport.written.getvalue() headers, response = result.split('\r\n\r\n')