././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4622188 wsproto-1.2.0/0000775000175000017500000000000000000000000014120 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284685.0 wsproto-1.2.0/CHANGELOG.rst0000664000175000017500000001265500000000000016152 0ustar00ubuntuubuntu00000000000000Release History =============== 1.2.0 (2022-08-23) ------------------ - Bugfix: When a close frame with status NO_STATUS_RCVD is sent, send and empty payload. - Bugfix: Changing both encoding and decoding of the Host, from ascii to idna. - Bugfix: Support multiple Sec-WebSocket-Extensions and Sec-WebSocket-Protocol headers. - Accept bytes alongside string as path argument in initiate_upgrade_connection. - Check the state when sending events, raising if the event cannot be sent in the current state. - Send an empty payload for NO_STATUS_RCVD. 1.1.0 (2022-02-27) ------------------ - Added support for Python 3.10. - Drop support for Python 3.6, meaning the minimum supported version is Python 3.7.0. - Various type checking and code linting improvements. 1.0.0 (2020-11-22) ------------------ - Added support for Python 3.8 and 3.9. - Prevent invalid window bit sizes. - Various docs, type checking, tooling and testing improvements. 0.15.0 (2019-08-10) ------------------- **This contains all the Bugfixes in the 0.14 branch.** - Drop support for Python 2. Please pin to ~= 0.14.0 if you support Python 2. - Drop support for Python 3.5, meaning the minimum supported version is Python 3.6.1. - Switch events to be dataclass based, otherwise the API is consistent. - Add type hints throughout and support PEP 561 via a py.typed file. This should allow projects that use wsproto to type check their usage of wsproto. - Bugfix prevent the test folder being installed as a package called test. - Explicitly require Host header in handshake. - Drop wsaccel support and utilise the aiohttp/@willmcgugan masking method. wsaccel is unmaintained and this new masking method is almost as quick. 0.14.1 (2019-05-30) ------------------- - Loosen the h11 requirement to >= 0.8.1 as wsproto is compatible with 0.9 onwards. - Stop installing a "test" package on installation. 0.14.0 (2019-04-06) ------------------- - Bugfix clarify subprotocol type as str not bytes. - Support HTTP/2 WebSockets. This requires a HTTP/2 parser (not included), with hyper-h2 recommended. It renames ``handshake_extensions`` and hence is a breaking change. - Bugfix badly formatted type hints. - Bugfix minor issues identified by type checking. 0.13.0 (2019-01-24) ------------------- - Introduce a send method on the connection which accepts the new events. This requires the following usage changes, :: connection.accept(subprotocol=subprotocol) -> connection.send(AcceptConnection(subprotocol=subprotocol)) connection.send_data(data) -> connection.send(Message(payload=payload)) connection.close(code) -> connection.send(CloseConnection(code=code)) connection.ping() -> connection.send(Ping()) connection.pong() -> connection.send(Pong()) - The Event structure is altered to allow for events to be sent and received, this requires the following name changes in existing code, :: ConnectionRequested -> Request ConnectionEstablished -> AcceptConnection ConnectionClosed -> CloseConnection DataReceived -> Message TextReceived -> TextMessage BytesReceived -> BytesMessage PingReceived -> Ping PongReceived -> Pong - Introduce RejectConnection and RejectData events to be used by a server connection to reject rather than accept a connection or by a client connection to emit the rejection response. The RejectData event represents the rejection response body, if present. - Add an extra_headers field to the AcceptConnection event in order to customise the acceptance response in server mode or to emit this information in client mode. - Switch from Fail events being returned to raising ``RemoteProtocolError``. - Switch from ValueError`s to LocalProtocolError`s being raised when an action is taken that is incompatible with the connection state or websocket standard. - Enforce version checking in SERVER mode, only 13 is supported. - Add an event_hint to RemoteProtocolErrors to hint at how to respond to issues. - Switch from a ``bytes_to_send`` method to the ``send`` method returning the bytes to send directly. Responses to Ping and Close messages must now be sent (via ``send``), with the ``Ping`` and ``CloseConnection`` events gaining a ``response`` method. This allows :: if isinstance(event, Ping): bytes_to_send = connection.send(event.response()) - Separate the handshake from the active connection handling. This allows the handshake and connection to be seperately used. By default though WSConnection does both. - ``receive_bytes`` is renamed to ``receive_data`` and ``WSConnection`` should be imported from ``wsproto`` rather than ``wsproto.connection``. 0.12.0 (2018-09-23) ------------------- - Support h11 ~0.8.1. - Support Python 3.7. - Make the close-handshake more explicit, by sending a close frame on reciept of a close frame. - Bugfix fix deflate after a non-compressable message. - Bugfix connection header acceptance, by accepting Connection header values that are comma separated lists. 0.11.0 (2017-12-31) ------------------- - Separate extension handling into its own method. - Add events for PING and PONG frames. - Stop supporting Python 3.4. - Large increase in tests and test coverage. - Bugfix extension accept for empty strings. - Bugfix raise if default arguments are invalid. 0.10.0 (2017-05-03) ------------------- - General improvements. 0.9.1 (2016-10-27) ------------------ - (unreleased on PyPI) 0.9.0 (2016-08-24) ------------------ - First release on PyPI. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/LICENSE0000664000175000017500000000210500000000000015123 0ustar00ubuntuubuntu00000000000000The MIT License (MIT) Copyright (c) 2017 Benno Rice and contributors 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.././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/MANIFEST.in0000664000175000017500000000057400000000000015664 0ustar00ubuntuubuntu00000000000000graft src/wsproto graft compliance graft example graft docs graft test graft bench prune docs/build prune compliance/reports prune compliance/auto-tests-server-config.json prune compliance/auto-tests-client-config.json prune compliance/autobahntestsuite-venv include README.rst LICENSE CHANGELOG.rst tox.ini global-exclude *.pyc *.pyo *.swo *.swp *.map *.yml *.DS_Store .coverage ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4622188 wsproto-1.2.0/PKG-INFO0000664000175000017500000001270700000000000015224 0ustar00ubuntuubuntu00000000000000Metadata-Version: 2.1 Name: wsproto Version: 1.2.0 Summary: WebSockets state-machine based protocol implementation Home-page: https://github.com/python-hyper/wsproto/ Author: Benno Rice Author-email: benno@jeamland.net License: MIT License Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=3.7.0 Description-Content-Type: text/x-rst License-File: LICENSE ======================================================== Pure Python, pure state-machine WebSocket implementation ======================================================== .. image:: https://github.com/python-hyper/wsproto/workflows/CI/badge.svg :target: https://github.com/python-hyper/wsproto/actions :alt: Build Status .. image:: https://codecov.io/gh/python-hyper/wsproto/branch/main/graph/badge.svg :target: https://codecov.io/gh/python-hyper/wsproto :alt: Code Coverage .. image:: https://readthedocs.org/projects/wsproto/badge/?version=latest :target: https://wsproto.readthedocs.io/en/latest/ :alt: Documentation Status .. image:: https://img.shields.io/badge/chat-join_now-brightgreen.svg :target: https://gitter.im/python-hyper/community :alt: Chat community This repository contains a pure-Python implementation of a WebSocket protocol stack. It's written from the ground up to be embeddable in whatever program you choose to use, ensuring that you can communicate via WebSockets, as defined in `RFC6455 `_, regardless of your programming paradigm. This repository does not provide a parsing layer, a network layer, or any rules about concurrency. Instead, it's a purely in-memory solution, defined in terms of data actions and WebSocket frames. RFC6455 and Compression Extensions for WebSocket via `RFC7692 `_ are fully supported. wsproto supports Python 3.6.1 or higher. To install it, just run: .. code-block:: console $ pip install wsproto Usage ===== Let's assume you have some form of network socket available. wsproto client connections automatically generate a HTTP request to initiate the WebSocket handshake. To create a WebSocket client connection: .. code-block:: python from wsproto import WSConnection, ConnectionType from wsproto.events import Request ws = WSConnection(ConnectionType.CLIENT) ws.send(Request(host='echo.websocket.org', target='/')) To create a WebSocket server connection: .. code-block:: python from wsproto.connection import WSConnection, ConnectionType ws = WSConnection(ConnectionType.SERVER) Every time you send a message, or call a ping, or simply if you receive incoming data, wsproto might respond with some outgoing data that you have to send: .. code-block:: python some_socket.send(ws.bytes_to_send()) Both connection types need to receive incoming data: .. code-block:: python ws.receive_data(some_byte_string_of_data) And wsproto will issue events if the data contains any WebSocket messages or state changes: .. code-block:: python for event in ws.events(): if isinstance(event, Request): # only client connections get this event ws.send(AcceptConnection()) elif isinstance(event, CloseConnection): # guess nobody wants to talk to us any more... elif isinstance(event, TextMessage): print('We got text!', event.data) elif isinstance(event, BytesMessage): print('We got bytes!', event.data) Take a look at our docs for a `full list of events `! Testing ======= It passes the autobahn test suite completely and strictly in both client and server modes and using permessage-deflate. If you want to run the compliance tests, go into the compliance directory and then to test client mode, in one shell run the Autobahn test server: .. code-block:: console $ wstest -m fuzzingserver -s ws-fuzzingserver.json And in another shell run the test client: .. code-block:: console $ python test_client.py And to test server mode, run the test server: .. code-block:: console $ python test_server.py And in another shell run the Autobahn test client: .. code-block:: console $ wstest -m fuzzingclient -s ws-fuzzingclient.json Documentation ============= Documentation is available at https://wsproto.readthedocs.io/en/latest/. Contributing ============ ``wsproto`` welcomes contributions from anyone! Unlike many other projects we are happy to accept cosmetic contributions and small contributions, in addition to large feature requests and changes. Before you contribute (either by opening an issue or filing a pull request), please `read the contribution guidelines`_. .. _read the contribution guidelines: http://python-hyper.org/en/latest/contributing.html License ======= ``wsproto`` is made available under the MIT License. For more details, see the ``LICENSE`` file in the repository. Authors ======= ``wsproto`` was created by @jeamland, and is maintained by the python-hyper community. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/README.rst0000664000175000017500000001107200000000000015610 0ustar00ubuntuubuntu00000000000000======================================================== Pure Python, pure state-machine WebSocket implementation ======================================================== .. image:: https://github.com/python-hyper/wsproto/workflows/CI/badge.svg :target: https://github.com/python-hyper/wsproto/actions :alt: Build Status .. image:: https://codecov.io/gh/python-hyper/wsproto/branch/main/graph/badge.svg :target: https://codecov.io/gh/python-hyper/wsproto :alt: Code Coverage .. image:: https://readthedocs.org/projects/wsproto/badge/?version=latest :target: https://wsproto.readthedocs.io/en/latest/ :alt: Documentation Status .. image:: https://img.shields.io/badge/chat-join_now-brightgreen.svg :target: https://gitter.im/python-hyper/community :alt: Chat community This repository contains a pure-Python implementation of a WebSocket protocol stack. It's written from the ground up to be embeddable in whatever program you choose to use, ensuring that you can communicate via WebSockets, as defined in `RFC6455 `_, regardless of your programming paradigm. This repository does not provide a parsing layer, a network layer, or any rules about concurrency. Instead, it's a purely in-memory solution, defined in terms of data actions and WebSocket frames. RFC6455 and Compression Extensions for WebSocket via `RFC7692 `_ are fully supported. wsproto supports Python 3.6.1 or higher. To install it, just run: .. code-block:: console $ pip install wsproto Usage ===== Let's assume you have some form of network socket available. wsproto client connections automatically generate a HTTP request to initiate the WebSocket handshake. To create a WebSocket client connection: .. code-block:: python from wsproto import WSConnection, ConnectionType from wsproto.events import Request ws = WSConnection(ConnectionType.CLIENT) ws.send(Request(host='echo.websocket.org', target='/')) To create a WebSocket server connection: .. code-block:: python from wsproto.connection import WSConnection, ConnectionType ws = WSConnection(ConnectionType.SERVER) Every time you send a message, or call a ping, or simply if you receive incoming data, wsproto might respond with some outgoing data that you have to send: .. code-block:: python some_socket.send(ws.bytes_to_send()) Both connection types need to receive incoming data: .. code-block:: python ws.receive_data(some_byte_string_of_data) And wsproto will issue events if the data contains any WebSocket messages or state changes: .. code-block:: python for event in ws.events(): if isinstance(event, Request): # only client connections get this event ws.send(AcceptConnection()) elif isinstance(event, CloseConnection): # guess nobody wants to talk to us any more... elif isinstance(event, TextMessage): print('We got text!', event.data) elif isinstance(event, BytesMessage): print('We got bytes!', event.data) Take a look at our docs for a `full list of events `! Testing ======= It passes the autobahn test suite completely and strictly in both client and server modes and using permessage-deflate. If you want to run the compliance tests, go into the compliance directory and then to test client mode, in one shell run the Autobahn test server: .. code-block:: console $ wstest -m fuzzingserver -s ws-fuzzingserver.json And in another shell run the test client: .. code-block:: console $ python test_client.py And to test server mode, run the test server: .. code-block:: console $ python test_server.py And in another shell run the Autobahn test client: .. code-block:: console $ wstest -m fuzzingclient -s ws-fuzzingclient.json Documentation ============= Documentation is available at https://wsproto.readthedocs.io/en/latest/. Contributing ============ ``wsproto`` welcomes contributions from anyone! Unlike many other projects we are happy to accept cosmetic contributions and small contributions, in addition to large feature requests and changes. Before you contribute (either by opening an issue or filing a pull request), please `read the contribution guidelines`_. .. _read the contribution guidelines: http://python-hyper.org/en/latest/contributing.html License ======= ``wsproto`` is made available under the MIT License. For more details, see the ``LICENSE`` file in the repository. Authors ======= ``wsproto`` was created by @jeamland, and is maintained by the python-hyper community. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/bench/0000775000175000017500000000000000000000000015177 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/bench/connection.py0000664000175000017500000000262200000000000017712 0ustar00ubuntuubuntu00000000000000import random import time from typing import List import wsproto random_seed = 0 mu = 125 * 1024 sigma = 75 * 1024 iterations = 5000 per_message_deflate = False rand = random.Random(random_seed) client_extensions: List[wsproto.extensions.Extension] = [] if per_message_deflate: pmd = wsproto.extensions.PerMessageDeflate() offer = pmd.offer() assert isinstance(offer, str) pmd.finalize(offer) client_extensions.append(pmd) client = wsproto.connection.Connection( wsproto.ConnectionType.CLIENT, extensions=client_extensions, ) server_extensions: List[wsproto.extensions.Extension] = [] if per_message_deflate: pmd = wsproto.extensions.PerMessageDeflate() offer = pmd.offer() assert isinstance(offer, str) pmd.accept(offer) server = wsproto.connection.Connection( wsproto.ConnectionType.SERVER, extensions=server_extensions, ) start = time.perf_counter() for i in range(iterations): client_msg = b"0" * max(0, round(rand.gauss(mu, sigma))) client_out = client.send(wsproto.events.BytesMessage(client_msg)) server.receive_data(client_out) for event in server.events(): pass server_msg = "0" * max(0, round(rand.gauss(mu, sigma))) server_out = server.send(wsproto.events.TextMessage(server_msg)) client.receive_data(server_out) for event in client.events(): pass end = time.perf_counter() print(f"{end - start:.4f}s") ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/compliance/0000775000175000017500000000000000000000000016232 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/compliance/run-autobahn-tests.py0000664000175000017500000001635700000000000022363 0ustar00ubuntuubuntu00000000000000# Things that would be nice: # - less hard-coding of paths here import argparse import copy import errno import json import os.path import socket import subprocess import sys import time from typing import Dict, List, Tuple PORT = 8642 CLIENT_CONFIG = { "options": {"failByDrop": False}, "outdir": "./reports/servers", "servers": [ { "agent": "wsproto", "url": f"ws://localhost:{PORT}", "options": {"version": 18}, } ], "cases": ["*"], "exclude-cases": ["13.3.*", "13.5.*", "13.7.*"], "exclude-agent-cases": {}, } SERVER_CONFIG = { "url": f"ws://localhost:{PORT}", "options": {"failByDrop": False}, "outdir": "./reports/clients", "webport": 8080, "cases": ["*"], "exclude-cases": ["13.3.*", "13.5.*", "13.7.*"], "exclude-agent-cases": {}, } CASES = { "all": ["*"], "fast": [ # The core functionality tests *[f"{i}.*" for i in range(1, 12)], # Compression tests -- in each section, the tests get progressively # slower until they're taking 10s of seconds apiece. And it's # mostly stress tests, without much extra coverage to show for # it. (Weird trick: autobahntestsuite treats these as regexps # except that . is quoted and * becomes .*) "12.*.[1234]$", "13.*.[1234]$", # At one point these were catching a unique bug that none of the # above were -- they're relatively quick and involve # fragmentation. "12.1.11", "12.1.12", "13.1.11", "13.1.12", ], } def say(*args: object) -> None: print("run-autobahn-tests.py:", *args) def setup_venv() -> None: if not os.path.exists("autobahntestsuite-venv"): say("Creating Python 2.7 environment and installing autobahntestsuite") subprocess.check_call( ["virtualenv", "-p", "python2.7", "autobahntestsuite-venv"] ) subprocess.check_call( ["autobahntestsuite-venv/bin/pip", "install", "autobahntestsuite>=0.8.0"] ) def wait_for_listener(port: int) -> None: while True: sock = socket.socket() try: sock.connect(("localhost", port)) except OSError as exc: if exc.errno == errno.ECONNREFUSED: time.sleep(0.01) else: raise else: return finally: sock.close() def coverage(command: List[str], coverage_settings: Dict[str, str]) -> List[str]: if not coverage_settings["enabled"]: return [sys.executable] + command return [ sys.executable, "-m", "coverage", "run", "--include", coverage_settings["wsproto-path"], ] + command def summarize(report_path: str) -> Tuple[int, int]: with open(os.path.join(report_path, "index.json")) as f: result_summary = json.load(f)["wsproto"] failed = 0 total = 0 PASS = {"OK", "INFORMATIONAL"} for test_name, results in sorted(result_summary.items()): total += 1 if results["behavior"] not in PASS or results["behaviorClose"] not in PASS: say("FAIL:", test_name, results) say("Details:") with open(os.path.join(report_path, results["reportfile"])) as f: print(f.read()) failed += 1 speed_ordered = sorted(result_summary.items(), key=lambda kv: -kv[1]["duration"]) say("Slowest tests:") for test_name, results in speed_ordered[:5]: say(" {}: {} seconds".format(test_name, results["duration"] / 1000)) return failed, total def run_client_tests( cases: List[str], coverage_settings: Dict[str, str] ) -> Tuple[int, int]: say("Starting autobahntestsuite server") server_config = copy.deepcopy(SERVER_CONFIG) server_config["cases"] = cases with open("auto-tests-server-config.json", "w") as f: json.dump(server_config, f) server = subprocess.Popen( [ "autobahntestsuite-venv/bin/wstest", "-m", "fuzzingserver", "-s", "auto-tests-server-config.json", ] ) say("Waiting for server to start") wait_for_listener(PORT) try: say("Running wsproto test client") subprocess.check_call(coverage(["./test_client.py"], coverage_settings)) # the client doesn't exit until the server closes the connection on the # /updateReports call, and the server doesn't close the connection until # after it writes the reports, so there's no race condition here. finally: say("Stopping server...") server.terminate() server.wait() return summarize("reports/clients") def run_server_tests( cases: List[str], coverage_settings: Dict[str, str] ) -> Tuple[int, int]: say("Starting wsproto test server") server = subprocess.Popen(coverage(["./test_server.py"], coverage_settings)) try: say("Waiting for server to start") wait_for_listener(PORT) client_config = copy.deepcopy(CLIENT_CONFIG) client_config["cases"] = cases with open("auto-tests-client-config.json", "w") as f: json.dump(client_config, f) say("Starting autobahntestsuite client") subprocess.check_call( [ "autobahntestsuite-venv/bin/wstest", "-m", "fuzzingclient", "-s", "auto-tests-client-config.json", ] ) finally: say("Stopping server...") # Connection on this port triggers a shutdown sock = socket.socket() sock.connect(("localhost", PORT + 1)) sock.close() server.wait() return summarize("reports/servers") def main() -> None: if not os.path.exists("test_client.py"): say("Run me from the compliance/ directory") sys.exit(2) coverage_settings = {"coveragerc": "../.coveragerc"} try: import wsproto # pylint: disable=import-outside-toplevel except ImportError: say("wsproto must be on python path -- set PYTHONPATH or install it") sys.exit(2) else: coverage_settings["wsproto-path"] = os.path.dirname(wsproto.__file__) parser = argparse.ArgumentParser() parser.add_argument("MODE", help="'client' or 'server'") # can do e.g. # --cases='["1.*"]' parser.add_argument( "--cases", help="'fast' or 'all' or a JSON list", default="fast" ) parser.add_argument("--cov", help="enable coverage", action="store_true") args = parser.parse_args() coverage_settings["enabled"] = args.cov cases = args.cases # pylint: disable=consider-using-get if cases in CASES: cases = CASES[cases] else: cases = json.loads(cases) setup_venv() if args.MODE == "client": failed, total = run_client_tests(cases, coverage_settings) elif args.MODE == "server": failed, total = run_server_tests(cases, coverage_settings) else: say("Unrecognized mode, try 'client' or 'server'") sys.exit(2) say(f"in {args.MODE.upper()} mode: failed {failed} out of {total} total") if failed: say("Test failed") sys.exit(1) else: say("SUCCESS!") if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/compliance/test_client.py0000664000175000017500000001045400000000000021125 0ustar00ubuntuubuntu00000000000000import json import socket from typing import Optional from urllib.parse import urlparse from wsproto import WSConnection from wsproto.connection import CLIENT from wsproto.events import ( AcceptConnection, CloseConnection, Message, Ping, Request, TextMessage, ) from wsproto.extensions import PerMessageDeflate from wsproto.frame_protocol import CloseReason SERVER = "ws://127.0.0.1:8642" AGENT = "wsproto" CONNECTION_EXCEPTIONS = (ConnectionError, OSError) def get_case_count(server: str) -> int: uri = urlparse(server + "/getCaseCount") connection = WSConnection(CLIENT) sock = socket.socket() sock.connect((uri.hostname, uri.port or 80)) sock.sendall(connection.send(Request(host=uri.netloc, target=uri.path))) case_count: Optional[int] = None while case_count is None: in_data = sock.recv(65535) connection.receive_data(in_data) data = "" out_data = b"" for event in connection.events(): if isinstance(event, TextMessage): data += event.data if event.message_finished: case_count = json.loads(data) out_data += connection.send( CloseConnection(code=CloseReason.NORMAL_CLOSURE) ) try: sock.sendall(out_data) except CONNECTION_EXCEPTIONS: break sock.close() return case_count def run_case(server: str, case: int, agent: str) -> None: uri = urlparse(server + "/runCase?case=%d&agent=%s" % (case, agent)) connection = WSConnection(CLIENT) sock = socket.socket() sock.connect((uri.hostname, uri.port or 80)) sock.sendall( connection.send( Request( host=uri.netloc, target=f"{uri.path}?{uri.query}", extensions=[PerMessageDeflate()], ) ) ) closed = False while not closed: try: data: Optional[bytes] = sock.recv(65535) except CONNECTION_EXCEPTIONS: data = None connection.receive_data(data or None) out_data = b"" for event in connection.events(): if isinstance(event, Message): out_data += connection.send( Message(data=event.data, message_finished=event.message_finished) ) elif isinstance(event, Ping): out_data += connection.send(event.response()) elif isinstance(event, CloseConnection): closed = True out_data += connection.send(event.response()) # else: # print("??", event) if out_data is None: break try: sock.sendall(out_data) except CONNECTION_EXCEPTIONS: closed = True break def update_reports(server: str, agent: str) -> None: uri = urlparse(server + "/updateReports?agent=%s" % agent) connection = WSConnection(CLIENT) sock = socket.socket() sock.connect((uri.hostname, uri.port or 80)) sock.sendall( connection.send(Request(host=uri.netloc, target=f"{uri.path}?{uri.query}")) ) closed = False while not closed: data = sock.recv(65535) connection.receive_data(data) for event in connection.events(): if isinstance(event, AcceptConnection): sock.sendall( connection.send(CloseConnection(code=CloseReason.NORMAL_CLOSURE)) ) try: sock.close() except CONNECTION_EXCEPTIONS: pass finally: closed = True CASE = None # 1.1.1 = 1 # 2.1 = 17 # 3.1 = 28 # 4.1.1 = 34 # 5.1 = 44 # 6.1.1 = 64 # 12.1.1 = 304 # 13.1.1 = 394 def run_tests(server: str, agent: str) -> None: case_count = get_case_count(server) if CASE is not None: print(">>>>> Running test case %d" % CASE) run_case(server, CASE, agent) else: for case in range(1, case_count + 1): print(">>>>> Running test case %d of %d" % (case, case_count)) run_case(server, case, agent) print("\nRan %d cases." % case_count) update_reports(server, agent) if __name__ == "__main__": run_tests(SERVER, AGENT) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/compliance/test_server.py0000664000175000017500000000460600000000000021157 0ustar00ubuntuubuntu00000000000000import select import socket from typing import Optional from wsproto import WSConnection from wsproto.connection import ConnectionState, SERVER from wsproto.events import AcceptConnection, CloseConnection, Message, Ping, Request from wsproto.extensions import PerMessageDeflate count = 0 def new_conn(sock: socket.socket) -> None: global count print(f"test_server.py received connection {count}") count += 1 ws = WSConnection(SERVER) closed = False while not closed: try: data: Optional[bytes] = sock.recv(65535) except OSError: data = None ws.receive_data(data or None) outgoing_data = b"" for event in ws.events(): if isinstance(event, Request): outgoing_data += ws.send( AcceptConnection(extensions=[PerMessageDeflate()]) ) elif isinstance(event, Message): outgoing_data += ws.send( Message(data=event.data, message_finished=event.message_finished) ) elif isinstance(event, Ping): outgoing_data += ws.send(event.response()) elif isinstance(event, CloseConnection): closed = True if ws.state is not ConnectionState.CLOSED: outgoing_data += ws.send(event.response()) if not data: closed = True try: sock.sendall(outgoing_data) except OSError: closed = True sock.close() def start_listener( host: str = "127.0.0.1", port: int = 8642, shutdown_port: int = 8643 ) -> None: server = socket.socket() server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((host, port)) server.listen(1) shutdown_server = socket.socket() shutdown_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) shutdown_server.bind((host, shutdown_port)) shutdown_server.listen(1) done = False filenos = {s.fileno(): s for s in (server, shutdown_server)} while not done: r, _, _ = select.select(list(filenos.keys()), [], [], 0) for sock in [filenos[fd] for fd in r]: if sock is server: new_conn(server.accept()[0]) else: done = True if __name__ == "__main__": try: start_listener() except KeyboardInterrupt: pass ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/compliance/ws-fuzzingclient.json0000664000175000017500000000036500000000000022453 0ustar00ubuntuubuntu00000000000000{ "options": {"failByDrop": false}, "outdir": "./reports/servers", "servers": [{"agent": "wsproto", "url": "ws://localhost:8642", "options": {"version": 18}}], "cases": ["*"], "exclude-cases": [], "exclude-agent-cases": {} } ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/compliance/ws-fuzzingserver.json0000664000175000017500000000031200000000000022473 0ustar00ubuntuubuntu00000000000000{ "url": "ws://localhost:8642", "options": {"failByDrop": false}, "outdir": "./reports/clients", "webport": 8080, "cases": ["*"], "exclude-cases": [], "exclude-agent-cases": {} } ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/docs/0000775000175000017500000000000000000000000015050 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/Makefile0000664000175000017500000000117600000000000016515 0ustar00ubuntuubuntu00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/make.bat0000664000175000017500000000143700000000000016462 0ustar00ubuntuubuntu00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/docs/source/0000775000175000017500000000000000000000000016350 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/docs/source/_static/0000775000175000017500000000000000000000000017776 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/_static/.keep0000664000175000017500000000000000000000000020711 0ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/advanced-usage.rst0000664000175000017500000001235500000000000021757 0ustar00ubuntuubuntu00000000000000Advanced Usage ============== This document explains some of the more advanced usage concepts with `wsproto`. This is assume you are familiar with `wsproto` and I/O in Python. Back-pressure ------------- Back-pressure is an important concept to understand when implementing a client/server protocol. This section briefly explains the issue and then explains how to handle back-pressure when using `wsproto`. Imagine that you have a WebSocket server that reads messages from the client, does some processing, and then sends a response. What happens if the client sends messages faster than the server can process them? If the incoming messages are buffered in memory, then the server will slowly use more and more memory, until the OS eventually kills it. This scenario is directly applicable to `wsproto`, because every time you call ``receive_data(some_byte_string_of_data)``, it appends that data to an internal buffer. The slow endpoint needs a way to signal the fast endpoint to stop sending messages until the slow endpoint can catch up. This signaling is called "back-pressure". As a Sans-IO library, `wsproto` is not responsible for network concerns like back-pressure, so that responsibility belongs to your network glue code. Fortunately, TCP has the ability to signal backpressure, and the operating system will do that for you automatically—if you follow a few rules! The OS buffers all incoming and outgoing network data. Standard Python socket methods, such as ``send(...)`` and ``recv()``, copy data to and from those OS buffers. For example, if the peer is sending data too quickly, then the OS receive buffer will start to get full, and the OS will signal the peer to stop transmitting. When ``recv()`` is called, the OS will copy data from its internal buffer into your process, free up space in its own buffer, and then signal to the peer to start transmitting again. Therefore, you need to follow these two rules to implement back-pressure over TCP: #. Do not receive from the socket faster than your code can process the messages. Your processing code may need to signal the receiving code when its ready to receive more data. #. Do not store out-going messages in an unbounded collection. Ideally, out-going messages should be sent to the OS as soon as possible. If you need to buffer messages in memory, the buffer should be bounded so that it can not grow indefinitely. Post handshake connection ------------------------- A WebSocket connection starts with a handshake, which is an agreement to use the WebSocket protocol, and on which sub-protocol and extensions to use. It can be advantageous to perform this handshake outside of `wsproto`, for example in a dual stack setup whereby the HTTP handling is completed seperately. In this case the :class:`Connection ` class can be used directly. .. code-block:: python connection = Connection(extensions) # Agreed extensions sock.send(connection.send(Message(data=b"Hi"))) connection.receive_data(sock.recv(4096)) for event in connection.events(): # As with WSConnection, only without any handshake events HTTP/2 ------ WebSockets over HTTP/2 have a distinct difference to HTTP/1 in that only a single HTTP/2 stream is dedicated to the WebSocket rather than the entire connection (as in HTTP/1). This requires the HTTP/2 connection to be managed before the WebSocket connection with `Hyper-h2 `_ being recommended for HTTP/2. Although `wsproto` doesn't manage the HTTP/2 connection it can still be used for the WebSocket stream. The HTTP/2 connection will need to handshake the WebSocket stream, with the key being agreement on the extensions used. Once the extensions have been agreed the :class:`Connection ` class can be used to manage the WebSocket connection, noting that data to be sent or received will need to be parsed by the HTTP/2 connection first. In practice for a server this looks like, .. code-block:: python from wsproto.connection import Connection, ConnectionType from wsproto.extensions import PerMessageDeflate from wsproto.handshake import server_extensions_handshake # WebSocket request has been received request_extensions: List[str] supported_extensions = [PerMessageDeflate()] accepts = server_extensions_handshake(request_extensions, supported_extensions) if accepts: response_headers.append({"sec-websocket-extensions": accepts}) # Send the response headers connection = Connection(ConnectionType.SERVER, supported_extensions) and for a client .. code-block:: python from wsproto.connection import Connection, ConnectionType from wsproto.extensions import PerMessageDeflate from wsproto.handshake import client_extensions_handshake # WebSocket response has been received accepted_extensions: List[str] proposed_extensions = [PerMessageDeflate()] extensions = client_extensions_handshake(accepted_extensions, proposed_extensions) connection = Connection(ConnectionType.CLIENT, supported_extensions) any data received on the stream should be passed to the ``connection`` via the ``receive_bytes`` method and bytes returned from the ``connection.send`` method should be wrapped in a HTTP/2 data frame and sent. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/api.rst0000664000175000017500000000402000000000000017647 0ustar00ubuntuubuntu00000000000000wsproto API ============ This document details the API of wsproto. Semantic Versioning ------------------- wsproto follows semantic versioning for its public API. Please note that the guarantees of semantic versioning apply only to the API that is *documented here*. Simply because a method or data field is not prefaced by an underscore does not make it part of wsproto's public API. Anything not documented here is subject to change at any time. Connection ---------- .. autoclass:: wsproto.WSConnection :special-members: __init__ :members: .. autoclass:: wsproto.ConnectionType :members: .. autoclass:: wsproto.connection.ConnectionState :members: Handshake --------- .. autoclass:: wsproto.handshake.H11Handshake :members: .. autofunction:: wsproto.handshake.client_extensions_handshake .. autofunction:: wsproto.handshake.server_extensions_handshake Events ------ Event constructors accept any field as a keyword argument. Some fields are required, while others have default values. .. autoclass:: wsproto.events.Event :members: .. autoclass:: wsproto.events.Request :members: .. autoclass:: wsproto.events.AcceptConnection :members: .. autoclass:: wsproto.events.RejectConnection :members: .. autoclass:: wsproto.events.RejectData :members: .. autoclass:: wsproto.events.CloseConnection :members: .. autoclass:: wsproto.events.Message :members: .. autoclass:: wsproto.events.TextMessage :members: .. autoclass:: wsproto.events.BytesMessage :members: .. autoclass:: wsproto.events.Ping :members: .. autoclass:: wsproto.events.Pong :members: Frame Protocol -------------- .. autoclass:: wsproto.frame_protocol.Opcode :members: .. autoclass:: wsproto.frame_protocol.CloseReason :members: Extensions ---------- .. autoclass:: wsproto.extensions.Extension :members: .. autodata:: wsproto.extensions.SUPPORTED_EXTENSIONS Exceptions ---------- .. autoclass:: wsproto.utilities.LocalProtocolError :members: .. autoclass:: wsproto.utilities.RemoteProtocolError :members: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/basic-usage.rst0000664000175000017500000002077100000000000021274 0ustar00ubuntuubuntu00000000000000Getting Started =============== .. currentmodule:: wsproto This document explains how to get started using wsproto to connect to WebSocket servers as well as how to write your own. We assume some level of familiarity with writing Python and networking code. If you're not familiar with these we highly recommend `you read up on these first `_. It may also be helpful `to study Sans-I/O `_, which describes the ideas behind writing a network protocol library that doesn't do any network I/O. Connections ----------- The main class you'll be working with is the :class:`WSConnection` object. This object represents a connection to a WebSocket peer. This class can handle both WebSocket clients and WebSocket servers. ``wsproto`` provides two layers of abstractions. You need to write code that interfaces with both of these layers. The following diagram illustrates how your code is like a sandwich around ``wsproto``. +----------------------+ | Application | +----------------------+ | **APPLICATION GLUE** | +----------------------+ | wsproto | +----------------------+ | **NETWORK GLUE** | +----------------------+ | Network Layer | +----------------------+ ``wsproto`` does not do perform any network I/O, so **NETWORK GLUE** represents the code you need to write to glue ``wsproto`` to an actual network, for example using Python's `socket `_ module. The :class:`WSConnection` class provides two methods for this purpose. When data has been received on a network socket, you should feed this data into a connection instance by calling :meth:`WSConnection.receive_data`. When you want to communicate with the remote peer, e.g. send a message, ping, or close the connection, you should create an instance of one of the :class:`wsproto.events.Event` subclasses and pass it to :meth:`WSConnection.send` to get the corresponding bytes that need to be sent. Your code is responsible for actually sending that data over the network. .. note:: If the connection drops, a standard Python ``socket.recv()`` will return zero bytes. You should call ``receive_data(None)`` to update the internal ``wsproto`` state to indicate that the connection has been closed. Internally, ``wsproto`` processes the raw network data you feed into it and turns it into higher level representations of WebSocket events. In **APPLICATION GLUE**, you need to write code to process these events. Incoming data is exposed though the generator method :meth:`WSConnection.events`, which yields WebSocket events. Each event is an instance of an :class:`.events.Event` subclass. WebSocket Clients ----------------- Begin by instantiating a connection object in client mode and then create a :class:`wsproto.events.Request` instance. The Request must specify ``host`` and ``target`` arguments. If the WebSocket server is located at ``http://example.com/foo``, then you would instantiate the connection as follows:: from wsproto import ConnectionType, WSConnection from wsproto.events import Request ws = WSConnection(ConnectionType.CLIENT) request = Request(host="example.com", target='foo') data = ws.send(request) Keep in mind that ``wsproto`` does not do any network I/O. Instead, :meth:`WSConnection.send` returns data that you must send to the remote peer. Here is an example using a standard Python socket:: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("example.com", 80)) sock.send(data) To receive communications from the peer, you must pass the data received from the peer into the connection instance:: data = sock.recv(4096) ws.receive_data(data) The connection instance parses the received data and determines if any high-level events have occurred, such as receiving a ping or a message. To retrieve these events, use the generator function :meth:`WSConnection.events`:: for event in ws.events(): if isinstance(event, AcceptConnection): print('Connection established') elif isinstance(event, RejectConnection): print('Connection rejected') elif isinstance(event, CloseConnection): print('Connection closed: code={} reason={}'.format( event.code, event.reason )) sock.send(ws.send(event.response())) elif isinstance(event, Ping): print('Received Ping frame with payload {}'.format(event.payload)) sock.send(ws.send(event.response())) elif isinstance(event, TextMessage): print('Received TEXT data: {}'.format(event.data)) if event.message_finished: print('Message finished.') elif isinstance(event, BytesMessage): print('Received BINARY data: {}'.format(event.data)) if event.message_finished: print('BINARY Message finished.') else: print('Unknown event: {!r}'.format(event)) The method ``events()`` returns a generator which will yield events for all of the data currently in the ``wsproto`` internal buffer and then exit. Therefore, you should iterate over this generator after receiving new network data. For a more complete example, see `synchronous_client.py `_. WebSocket Servers ----------------- A WebSocket server is similar to a client, but it uses a different :class:`wsproto.ConnectionType` constant. :: from wsproto import ConnectionType, WSConnection from wsproto.events import Request ws = WSConnection(ConnectionType.SERVER) A server also needs to explicitly send an :class:`AcceptConnection ` after it receives a ``Request`` event:: for event in ws.events(): if isinstance(event, Request): print('Accepting connection request') sock.send(ws.send(AcceptConnection())) elif... Alternatively a server can explicitly reject the connection by sending :class:`RejectConnection ` after receiving a ``Request`` event. For a more complete example, see `synchronous_server.py `_. Protocol Errors --------------- Protocol errors relating to either incorrect data or incorrect state changes are raised when the connection receives data or when events are sent. A :class:`LocalProtocolError ` is raised if the local actions are in error whereas a :class:`RemoteProtocolError ` is raised if the remote actions are in error. Closing ------- WebSockets are closed with a handshake that requires each endpoint to send one frame and receive one frame. Sending a :class:`CloseConnection ` instance sets the state to ``LOCAL_CLOSING``. When a close frame is received, it yields a ``CloseConnection`` event, sets the state to ``REMOTE_CLOSING`` **and requires a reply to be sent**. This reply should be a ``CloseConnection`` event. To aid with this the ``CloseConnection`` class has a :meth:`response() ` method to create the appropriate reply. For example, .. code-block:: python if isinstance(event, CloseConnection): sock.send(ws.send(event.response())) When the reply has been received by the initiator, it will also yield a ``CloseConnection`` event. Regardless of which endpoint initiates the closing handshake, the server is responsible for tearing down the underlying connection. When a ``CloseConnection`` event is generated, it should send pending any ``wsproto`` data and then tear down the underlying connection. .. note:: Both client and server connections must remember to reply to ``CloseConnection`` events initiated by the remote party. Ping Pong --------- The :class:`WSConnection ` class supports sending WebSocket ping and pong frames via sending :class:`Ping ` and :class:`Pong `. When a ``Ping`` frame is received it **requires a reply**, this reply should be a ``Pong`` event. To aid with this the ``Ping`` class has a :meth:`response() ` method to create the appropriate reply. For example, .. code-block:: python if isinstance(event, Ping): sock.send(ws.send(event.response())) .. note:: Both client and server connections must remember to reply to ``Ping`` events initiated by the remote party. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/conf.py0000664000175000017500000000453600000000000017657 0ustar00ubuntuubuntu00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys import re sys.path.insert(0, os.path.abspath('../..')) PROJECT_ROOT = os.path.dirname(__file__) # Get the version version_regex = r'__version__ = ["\']([^"\']*)["\']' with open(os.path.join(PROJECT_ROOT, '../../', 'src/wsproto/__init__.py')) as file_: text = file_.read() match = re.search(version_regex, text) version = match.group(1) # -- Project information ----------------------------------------------------- project = 'wsproto' copyright = '2020, Benno Rice' author = 'Benno Rice' release = version # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'python': ('https://docs.python.org/', None), } master_doc = 'index' # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'default' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/index.rst0000664000175000017500000000216700000000000020217 0ustar00ubuntuubuntu00000000000000.. wsproto documentation master file, created by sphinx-quickstart on Wed Aug 24 10:37:29 2016. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. wsproto: A pure Python WebSocket protocol stack =============================================== wsproto is a WebSocket protocol stack written to be as flexible as possible. To that end it is written in pure Python and performs no I/O of its own. Instead it relies on the user to provide a bridge between it and whichever I/O mechanism is in use, allowing it to be used in single-threaded, multi-threaded or event-driven code. The goal for wsproto is 100% compliance with `RFC 6455`_. Additionally a mechanism is provided to add extensions allowing the implementation of extra functionally such as per-message compression as specified in `RFC 7692`_. For usage examples, see :doc:`basic-usage` or see the examples provided. Contents: .. toctree:: :maxdepth: 2 installation basic-usage advanced-usage api .. _RFC 6455: https://tools.ietf.org/html/rfc6455 .. _RFC 7692: https://tools.ietf.org/html/rfc7692 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/docs/source/installation.rst0000664000175000017500000000060700000000000021606 0ustar00ubuntuubuntu00000000000000Installation ============ wsproto is a pure Python project. To install it you can use pip like so: .. code-block:: console $ pip install wsproto Alternatively you can get either a release tarball or a development branch from `our GitHub repository`_ and run: .. code-block:: console $ python setup.py install .. _our GitHub repository: https://github.com/python-hyper/wsproto ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/example/0000775000175000017500000000000000000000000015553 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/example/synchronous_client.py0000664000175000017500000000711000000000000022054 0ustar00ubuntuubuntu00000000000000""" The client reads a line from stdin, sends it to the server, then prints the response. This is a poor implementation of a client. It is only intended to demonstrate how to use wsproto. """ import socket import sys from wsproto import WSConnection from wsproto.connection import ConnectionType from wsproto.events import ( AcceptConnection, CloseConnection, Message, Ping, Pong, Request, TextMessage, ) RECEIVE_BYTES = 4096 def main() -> None: """Run the client.""" try: host = sys.argv[1] port = int(sys.argv[2]) except (IndexError, ValueError): print("Usage: {} ".format(sys.argv[0])) sys.exit(1) try: wsproto_demo(host, port) except KeyboardInterrupt: print("\nReceived SIGINT: shutting down…") def wsproto_demo(host: str, port: int) -> None: """ Demonstrate wsproto: 0) Open TCP connection 1) Negotiate WebSocket opening handshake 2) Send a message and display response 3) Send ping and display pong 4) Negotiate WebSocket closing handshake """ # 0) Open TCP connection print(f"Connecting to {host}:{port}") conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) conn.connect((host, port)) # 1) Negotiate WebSocket opening handshake print("Opening WebSocket") ws = WSConnection(ConnectionType.CLIENT) # Because this is a client WebSocket, we need to initiate the connection # handshake by sending a Request event. net_send(ws.send(Request(host=host, target="server")), conn) net_recv(ws, conn) handle_events(ws) # 2) Send a message and display response message = "wsproto is great" print(f"Sending message: {message}") net_send(ws.send(Message(data=message)), conn) net_recv(ws, conn) handle_events(ws) # 3) Send ping and display pong payload = b"table tennis" print(f"Sending ping: {payload!r}") net_send(ws.send(Ping(payload=payload)), conn) net_recv(ws, conn) handle_events(ws) # 4) Negotiate WebSocket closing handshake print("Closing WebSocket") net_send(ws.send(CloseConnection(code=1000, reason="sample reason")), conn) # After sending the closing frame, we won't get any more events. The server # should send a reply and then close the connection, so we need to receive # twice: net_recv(ws, conn) conn.shutdown(socket.SHUT_WR) net_recv(ws, conn) def net_send(out_data: bytes, conn: socket.socket) -> None: """Write pending data from websocket to network.""" print("Sending {} bytes".format(len(out_data))) conn.send(out_data) def net_recv(ws: WSConnection, conn: socket.socket) -> None: """Read pending data from network into websocket.""" in_data = conn.recv(RECEIVE_BYTES) if not in_data: # A receive of zero bytes indicates the TCP socket has been closed. We # need to pass None to wsproto to update its internal state. print("Received 0 bytes (connection closed)") ws.receive_data(None) else: print("Received {} bytes".format(len(in_data))) ws.receive_data(in_data) def handle_events(ws: WSConnection) -> None: for event in ws.events(): if isinstance(event, AcceptConnection): print("WebSocket negotiation complete") elif isinstance(event, TextMessage): print(f"Received message: {event.data}") elif isinstance(event, Pong): print(f"Received pong: {event.payload!r}") else: raise Exception("Do not know how to handle event: " + str(event)) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/example/synchronous_server.py0000664000175000017500000000652300000000000022113 0ustar00ubuntuubuntu00000000000000""" This server reads a message from a WebSocket, and sends the reverse string in a response message. It can only handle one client at a time. This is a very bad implementation of a server! It is only intended to demonstrate how to use wsproto. """ import socket import sys from wsproto import ConnectionType, WSConnection from wsproto.events import ( AcceptConnection, CloseConnection, Message, Ping, Request, TextMessage, ) MAX_CONNECTS = 5 RECEIVE_BYTES = 4096 def main() -> None: """Run the server.""" try: ip = sys.argv[1] port = int(sys.argv[2]) except (IndexError, ValueError): print("Usage: {} ".format(sys.argv[0])) sys.exit(1) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((ip, port)) server.listen(0) try: while True: print("Waiting for connection...") (stream, addr) = server.accept() print("Client connected: {}:{}".format(addr[0], addr[1])) handle_connection(stream) stream.shutdown(socket.SHUT_WR) stream.close() except KeyboardInterrupt: print("Received SIGINT: shutting down…") def handle_connection(stream: socket.socket) -> None: """ Handle a connection. The server operates a request/response cycle, so it performs a synchronous loop: 1) Read data from network into wsproto 2) Get new events and handle them 3) Send data from wsproto to network :param stream: a socket stream """ ws = WSConnection(ConnectionType.SERVER) running = True while running: # 1) Read data from network in_data = stream.recv(RECEIVE_BYTES) print("Received {} bytes".format(len(in_data))) ws.receive_data(in_data) # 2) Get new events and handle them out_data = b"" for event in ws.events(): if isinstance(event, Request): # Negotiate new WebSocket connection print("Accepting WebSocket upgrade") out_data += ws.send(AcceptConnection()) elif isinstance(event, CloseConnection): # Print log message and break out print( "Connection closed: code={} reason={}".format( event.code, event.reason ) ) out_data += ws.send(event.response()) running = False elif isinstance(event, TextMessage): # Reverse text and send it back to wsproto print("Received request and sending response") out_data += ws.send(Message(data=event.data[::-1])) elif isinstance(event, Ping): # wsproto handles ping events for you by placing a pong frame in # the outgoing buffer. You should not call pong() unless you want to # send an unsolicited pong frame. print("Received ping and sending pong") out_data += ws.send(event.response()) else: print(f"Unknown event: {event!r}") # 4) Send data from wsproto to network print("Sending {} bytes".format(len(out_data))) stream.send(out_data) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4622188 wsproto-1.2.0/setup.cfg0000664000175000017500000000130600000000000015741 0ustar00ubuntuubuntu00000000000000[tool:pytest] testpaths = test [coverage:run] branch = True source = wsproto [coverage:report] show_missing = True exclude_lines = pragma: no cover raise NotImplementedError() [coverage:paths] source = src .tox/*/site-packages [flake8] max-line-length = 120 max-complexity = 15 ignore = E203,W503,W504 [isort] combine_as_imports = True force_grid_wrap = 0 include_trailing_comma = True known_first_party = wsproto, test known_third_party = h11, pytest line_length = 88 multi_line_output = 3 no_lines_before = LOCALFOLDER order_by_type = False [mypy] strict = true warn_unused_configs = true show_error_codes = true [mypy-h11.*] ignore_missing_imports = True [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/setup.py0000664000175000017500000000323300000000000015633 0ustar00ubuntuubuntu00000000000000#!/usr/bin/env python3 import os import re from setuptools import setup, find_packages PROJECT_ROOT = os.path.dirname(__file__) with open(os.path.join(PROJECT_ROOT, 'README.rst')) as file_: long_description = file_.read() version_regex = r'__version__ = ["\']([^"\']*)["\']' with open(os.path.join(PROJECT_ROOT, 'src/wsproto/__init__.py')) as file_: text = file_.read() match = re.search(version_regex, text) if match: version = match.group(1) else: raise RuntimeError("No version number found!") setup( name='wsproto', version=version, description='WebSockets state-machine based protocol implementation', long_description=long_description, long_description_content_type='text/x-rst', author='Benno Rice', author_email='benno@jeamland.net', url='https://github.com/python-hyper/wsproto/', packages=find_packages(where="src"), package_data={'wsproto': ['py.typed']}, package_dir={'': 'src'}, python_requires='>=3.7.0', license='MIT License', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], install_requires=[ 'h11>=0.9.0,<1', ], ) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4582222 wsproto-1.2.0/src/0000775000175000017500000000000000000000000014707 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4622188 wsproto-1.2.0/src/wsproto/0000775000175000017500000000000000000000000016424 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284685.0 wsproto-1.2.0/src/wsproto/__init__.py0000664000175000017500000000550700000000000020544 0ustar00ubuntuubuntu00000000000000""" wsproto ~~~~~~~ A WebSocket implementation. """ from typing import Generator, Optional, Union from .connection import Connection, ConnectionState, ConnectionType from .events import Event from .handshake import H11Handshake from .typing import Headers __version__ = "1.2.0" class WSConnection: """ Represents the local end of a WebSocket connection to a remote peer. """ def __init__(self, connection_type: ConnectionType) -> None: """ Constructor :param wsproto.connection.ConnectionType connection_type: Controls whether the library behaves as a client or as a server. """ self.client = connection_type is ConnectionType.CLIENT self.handshake = H11Handshake(connection_type) self.connection: Optional[Connection] = None @property def state(self) -> ConnectionState: """ :returns: Connection state :rtype: wsproto.connection.ConnectionState """ if self.connection is None: return self.handshake.state return self.connection.state def initiate_upgrade_connection( self, headers: Headers, path: Union[bytes, str] ) -> None: self.handshake.initiate_upgrade_connection(headers, path) def send(self, event: Event) -> bytes: """ Generate network data for the specified event. When you want to communicate with a WebSocket peer, you should construct an event and pass it to this method. This method will return the bytes that you should send to the peer. :param wsproto.events.Event event: The event to generate data for :returns bytes: The data to send to the peer """ data = b"" if self.connection is None: data += self.handshake.send(event) self.connection = self.handshake.connection else: data += self.connection.send(event) return data def receive_data(self, data: Optional[bytes]) -> None: """ Feed network data into the connection instance. After calling this method, you should call :meth:`events` to see if the received data triggered any new events. :param bytes data: Data received from remote peer """ if self.connection is None: self.handshake.receive_data(data) self.connection = self.handshake.connection else: self.connection.receive_data(data) def events(self) -> Generator[Event, None, None]: """ A generator that yields pending events. Each event is an instance of a subclass of :class:`wsproto.events.Event`. """ yield from self.handshake.events() if self.connection is not None: yield from self.connection.events() __all__ = ("ConnectionType", "WSConnection") ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/connection.py0000664000175000017500000001523500000000000021143 0ustar00ubuntuubuntu00000000000000""" wsproto/connection ~~~~~~~~~~~~~~~~~~ An implementation of a WebSocket connection. """ from collections import deque from enum import Enum from typing import Deque, Generator, List, Optional from .events import ( BytesMessage, CloseConnection, Event, Message, Ping, Pong, TextMessage, ) from .extensions import Extension from .frame_protocol import CloseReason, FrameProtocol, Opcode, ParseFailed from .utilities import LocalProtocolError class ConnectionState(Enum): """ RFC 6455, Section 4 - Opening Handshake """ #: The opening handshake is in progress. CONNECTING = 0 #: The opening handshake is complete. OPEN = 1 #: The remote WebSocket has initiated a connection close. REMOTE_CLOSING = 2 #: The local WebSocket (i.e. this instance) has initiated a connection close. LOCAL_CLOSING = 3 #: The closing handshake has completed. CLOSED = 4 #: The connection was rejected during the opening handshake. REJECTING = 5 class ConnectionType(Enum): """An enumeration of connection types.""" #: This connection will act as client and talk to a remote server CLIENT = 1 #: This connection will as as server and waits for client connections SERVER = 2 CLIENT = ConnectionType.CLIENT SERVER = ConnectionType.SERVER class Connection: """ A low-level WebSocket connection object. This wraps two other protocol objects, an HTTP/1.1 protocol object used to do the initial HTTP upgrade handshake and a WebSocket frame protocol object used to exchange messages and other control frames. :param conn_type: Whether this object is on the client- or server-side of a connection. To initialise as a client pass ``CLIENT`` otherwise pass ``SERVER``. :type conn_type: ``ConnectionType`` """ def __init__( self, connection_type: ConnectionType, extensions: Optional[List[Extension]] = None, trailing_data: bytes = b"", ) -> None: self.client = connection_type is ConnectionType.CLIENT self._events: Deque[Event] = deque() self._proto = FrameProtocol(self.client, extensions or []) self._state = ConnectionState.OPEN self.receive_data(trailing_data) @property def state(self) -> ConnectionState: return self._state def send(self, event: Event) -> bytes: data = b"" if isinstance(event, Message) and self.state == ConnectionState.OPEN: data += self._proto.send_data(event.data, event.message_finished) elif isinstance(event, Ping) and self.state == ConnectionState.OPEN: data += self._proto.ping(event.payload) elif isinstance(event, Pong) and self.state == ConnectionState.OPEN: data += self._proto.pong(event.payload) elif isinstance(event, CloseConnection) and self.state in { ConnectionState.OPEN, ConnectionState.REMOTE_CLOSING, }: data += self._proto.close(event.code, event.reason) if self.state == ConnectionState.REMOTE_CLOSING: self._state = ConnectionState.CLOSED else: self._state = ConnectionState.LOCAL_CLOSING else: raise LocalProtocolError( f"Event {event} cannot be sent in state {self.state}." ) return data def receive_data(self, data: Optional[bytes]) -> None: """ Pass some received data to the connection for handling. A list of events that the remote peer triggered by sending this data can be retrieved with :meth:`~wsproto.connection.Connection.events`. :param data: The data received from the remote peer on the network. :type data: ``bytes`` """ if data is None: # "If _The WebSocket Connection is Closed_ and no Close control # frame was received by the endpoint (such as could occur if the # underlying transport connection is lost), _The WebSocket # Connection Close Code_ is considered to be 1006." self._events.append(CloseConnection(code=CloseReason.ABNORMAL_CLOSURE)) self._state = ConnectionState.CLOSED return if self.state in (ConnectionState.OPEN, ConnectionState.LOCAL_CLOSING): self._proto.receive_bytes(data) elif self.state is ConnectionState.CLOSED: raise LocalProtocolError("Connection already closed.") else: pass # pragma: no cover def events(self) -> Generator[Event, None, None]: """ Return a generator that provides any events that have been generated by protocol activity. :returns: generator of :class:`Event ` subclasses """ while self._events: yield self._events.popleft() try: for frame in self._proto.received_frames(): if frame.opcode is Opcode.PING: assert frame.frame_finished and frame.message_finished assert isinstance(frame.payload, (bytes, bytearray)) yield Ping(payload=frame.payload) elif frame.opcode is Opcode.PONG: assert frame.frame_finished and frame.message_finished assert isinstance(frame.payload, (bytes, bytearray)) yield Pong(payload=frame.payload) elif frame.opcode is Opcode.CLOSE: assert isinstance(frame.payload, tuple) code, reason = frame.payload if self.state is ConnectionState.LOCAL_CLOSING: self._state = ConnectionState.CLOSED else: self._state = ConnectionState.REMOTE_CLOSING yield CloseConnection(code=code, reason=reason) elif frame.opcode is Opcode.TEXT: assert isinstance(frame.payload, str) yield TextMessage( data=frame.payload, frame_finished=frame.frame_finished, message_finished=frame.message_finished, ) elif frame.opcode is Opcode.BINARY: assert isinstance(frame.payload, (bytes, bytearray)) yield BytesMessage( data=frame.payload, frame_finished=frame.frame_finished, message_finished=frame.message_finished, ) else: pass # pragma: no cover except ParseFailed as exc: yield CloseConnection(code=exc.code, reason=str(exc)) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/events.py0000664000175000017500000001745300000000000020314 0ustar00ubuntuubuntu00000000000000""" wsproto/events ~~~~~~~~~~~~~~ Events that result from processing data on a WebSocket connection. """ from abc import ABC from dataclasses import dataclass, field from typing import Generic, List, Optional, Sequence, TypeVar, Union from .extensions import Extension from .typing import Headers class Event(ABC): """ Base class for wsproto events. """ pass # noqa @dataclass(frozen=True) class Request(Event): """The beginning of a Websocket connection, the HTTP Upgrade request This event is fired when a SERVER connection receives a WebSocket handshake request (HTTP with upgrade header). Fields: .. attribute:: host (Required) The hostname, or host header value. .. attribute:: target (Required) The request target (path and query string) .. attribute:: extensions The proposed extensions. .. attribute:: extra_headers The additional request headers, excluding extensions, host, subprotocols, and version headers. .. attribute:: subprotocols A list of the subprotocols proposed in the request, as a list of strings. """ host: str target: str extensions: Union[Sequence[Extension], Sequence[str]] = field( # type: ignore[assignment] default_factory=list ) extra_headers: Headers = field(default_factory=list) subprotocols: List[str] = field(default_factory=list) @dataclass(frozen=True) class AcceptConnection(Event): """The acceptance of a Websocket upgrade request. This event is fired when a CLIENT receives an acceptance response from a server. It is also used to accept an upgrade request when acting as a SERVER. Fields: .. attribute:: extra_headers Any additional (non websocket related) headers present in the acceptance response. .. attribute:: subprotocol The accepted subprotocol to use. """ subprotocol: Optional[str] = None extensions: List[Extension] = field(default_factory=list) extra_headers: Headers = field(default_factory=list) @dataclass(frozen=True) class RejectConnection(Event): """The rejection of a Websocket upgrade request, the HTTP response. The ``RejectConnection`` event sends the appropriate HTTP headers to communicate to the peer that the handshake has been rejected. You may also send an HTTP body by setting the ``has_body`` attribute to ``True`` and then sending one or more :class:`RejectData` events after this one. When sending a response body, the caller should set the ``Content-Length``, ``Content-Type``, and/or ``Transfer-Encoding`` headers as appropriate. When receiving a ``RejectConnection`` event, the ``has_body`` attribute will in almost all cases be ``True`` (even if the server set it to ``False``) and will be followed by at least one ``RejectData`` events, even though the data itself might be just ``b""``. (The only scenario in which the caller receives a ``RejectConnection`` with ``has_body == False`` is if the peer violates sends an informational status code (1xx) other than 101.) The ``has_body`` attribute should only be used when receiving the event. (It has ) is False the headers must include a content-length or transfer encoding. Fields: .. attribute:: headers (Headers) The headers to send with the response. .. attribute:: has_body This defaults to False, but set to True if there is a body. See also :class:`~RejectData`. .. attribute:: status_code The response status code. """ status_code: int = 400 headers: Headers = field(default_factory=list) has_body: bool = False @dataclass(frozen=True) class RejectData(Event): """The rejection HTTP response body. The caller may send multiple ``RejectData`` events. The final event should have the ``body_finished`` attribute set to ``True``. Fields: .. attribute:: body_finished True if this is the final chunk of the body data. .. attribute:: data (bytes) (Required) The raw body data. """ data: bytes body_finished: bool = True @dataclass(frozen=True) class CloseConnection(Event): """The end of a Websocket connection, represents a closure frame. **wsproto does not automatically send a response to a close event.** To comply with the RFC you MUST send a close event back to the remote WebSocket if you have not already sent one. The :meth:`response` method provides a suitable event for this purpose, and you should check if a response needs to be sent by checking :func:`wsproto.WSConnection.state`. Fields: .. attribute:: code (Required) The integer close code to indicate why the connection has closed. .. attribute:: reason Additional reasoning for why the connection has closed. """ code: int reason: Optional[str] = None def response(self) -> "CloseConnection": """Generate an RFC-compliant close frame to send back to the peer.""" return CloseConnection(code=self.code, reason=self.reason) T = TypeVar("T", bytes, str) @dataclass(frozen=True) class Message(Event, Generic[T]): """The websocket data message. Fields: .. attribute:: data (Required) The message data as byte string, can be decoded as UTF-8 for TEXT messages. This only represents a single chunk of data and not a full WebSocket message. You need to buffer and reassemble these chunks to get the full message. .. attribute:: frame_finished This has no semantic content, but is provided just in case some weird edge case user wants to be able to reconstruct the fragmentation pattern of the original stream. .. attribute:: message_finished True if this frame is the last one of this message, False if more frames are expected. """ data: T frame_finished: bool = True message_finished: bool = True @dataclass(frozen=True) class TextMessage(Message[str]): # pylint: disable=unsubscriptable-object """This event is fired when a data frame with TEXT payload is received. Fields: .. attribute:: data The message data as string, This only represents a single chunk of data and not a full WebSocket message. You need to buffer and reassemble these chunks to get the full message. """ # https://github.com/python/mypy/issues/5744 data: str @dataclass(frozen=True) class BytesMessage(Message[bytes]): # pylint: disable=unsubscriptable-object """This event is fired when a data frame with BINARY payload is received. Fields: .. attribute:: data The message data as byte string, can be decoded as UTF-8 for TEXT messages. This only represents a single chunk of data and not a full WebSocket message. You need to buffer and reassemble these chunks to get the full message. """ # https://github.com/python/mypy/issues/5744 data: bytes @dataclass(frozen=True) class Ping(Event): """The Ping event can be sent to trigger a ping frame and is fired when a Ping is received. **wsproto does not automatically send a pong response to a ping event.** To comply with the RFC you MUST send a pong even as soon as is practical. The :meth:`response` method provides a suitable event for this purpose. Fields: .. attribute:: payload An optional payload to emit with the ping frame. """ payload: bytes = b"" def response(self) -> "Pong": """Generate an RFC-compliant :class:`Pong` response to this ping.""" return Pong(payload=self.payload) @dataclass(frozen=True) class Pong(Event): """The Pong event is fired when a Pong is received. Fields: .. attribute:: payload An optional payload to emit with the pong frame. """ payload: bytes = b"" ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/extensions.py0000664000175000017500000002571300000000000021205 0ustar00ubuntuubuntu00000000000000""" wsproto/extensions ~~~~~~~~~~~~~~~~~~ WebSocket extensions. """ import zlib from typing import Optional, Tuple, Union from .frame_protocol import CloseReason, FrameDecoder, FrameProtocol, Opcode, RsvBits class Extension: name: str def enabled(self) -> bool: return False def offer(self) -> Union[bool, str]: pass def accept(self, offer: str) -> Optional[Union[bool, str]]: pass def finalize(self, offer: str) -> None: pass def frame_inbound_header( self, proto: Union[FrameDecoder, FrameProtocol], opcode: Opcode, rsv: RsvBits, payload_length: int, ) -> Union[CloseReason, RsvBits]: return RsvBits(False, False, False) def frame_inbound_payload_data( self, proto: Union[FrameDecoder, FrameProtocol], data: bytes ) -> Union[bytes, CloseReason]: return data def frame_inbound_complete( self, proto: Union[FrameDecoder, FrameProtocol], fin: bool ) -> Union[bytes, CloseReason, None]: pass def frame_outbound( self, proto: Union[FrameDecoder, FrameProtocol], opcode: Opcode, rsv: RsvBits, data: bytes, fin: bool, ) -> Tuple[RsvBits, bytes]: return (rsv, data) class PerMessageDeflate(Extension): name = "permessage-deflate" DEFAULT_CLIENT_MAX_WINDOW_BITS = 15 DEFAULT_SERVER_MAX_WINDOW_BITS = 15 def __init__( self, client_no_context_takeover: bool = False, client_max_window_bits: Optional[int] = None, server_no_context_takeover: bool = False, server_max_window_bits: Optional[int] = None, ) -> None: self.client_no_context_takeover = client_no_context_takeover self.server_no_context_takeover = server_no_context_takeover self._client_max_window_bits = self.DEFAULT_CLIENT_MAX_WINDOW_BITS self._server_max_window_bits = self.DEFAULT_SERVER_MAX_WINDOW_BITS if client_max_window_bits is not None: self.client_max_window_bits = client_max_window_bits if server_max_window_bits is not None: self.server_max_window_bits = server_max_window_bits self._compressor: Optional[zlib._Compress] = None # noqa self._decompressor: Optional[zlib._Decompress] = None # noqa # This refers to the current frame self._inbound_is_compressible: Optional[bool] = None # This refers to the ongoing message (which might span multiple # frames). Only the first frame in a fragmented message is flagged for # compression, so this carries that bit forward. self._inbound_compressed: Optional[bool] = None self._enabled = False @property def client_max_window_bits(self) -> int: return self._client_max_window_bits @client_max_window_bits.setter def client_max_window_bits(self, value: int) -> None: if value < 9 or value > 15: raise ValueError("Window size must be between 9 and 15 inclusive") self._client_max_window_bits = value @property def server_max_window_bits(self) -> int: return self._server_max_window_bits @server_max_window_bits.setter def server_max_window_bits(self, value: int) -> None: if value < 9 or value > 15: raise ValueError("Window size must be between 9 and 15 inclusive") self._server_max_window_bits = value def _compressible_opcode(self, opcode: Opcode) -> bool: return opcode in (Opcode.TEXT, Opcode.BINARY, Opcode.CONTINUATION) def enabled(self) -> bool: return self._enabled def offer(self) -> Union[bool, str]: parameters = [ "client_max_window_bits=%d" % self.client_max_window_bits, "server_max_window_bits=%d" % self.server_max_window_bits, ] if self.client_no_context_takeover: parameters.append("client_no_context_takeover") if self.server_no_context_takeover: parameters.append("server_no_context_takeover") return "; ".join(parameters) def finalize(self, offer: str) -> None: bits = [b.strip() for b in offer.split(";")] for bit in bits[1:]: if bit.startswith("client_no_context_takeover"): self.client_no_context_takeover = True elif bit.startswith("server_no_context_takeover"): self.server_no_context_takeover = True elif bit.startswith("client_max_window_bits"): self.client_max_window_bits = int(bit.split("=", 1)[1].strip()) elif bit.startswith("server_max_window_bits"): self.server_max_window_bits = int(bit.split("=", 1)[1].strip()) self._enabled = True def _parse_params(self, params: str) -> Tuple[Optional[int], Optional[int]]: client_max_window_bits = None server_max_window_bits = None bits = [b.strip() for b in params.split(";")] for bit in bits[1:]: if bit.startswith("client_no_context_takeover"): self.client_no_context_takeover = True elif bit.startswith("server_no_context_takeover"): self.server_no_context_takeover = True elif bit.startswith("client_max_window_bits"): if "=" in bit: client_max_window_bits = int(bit.split("=", 1)[1].strip()) else: client_max_window_bits = self.client_max_window_bits elif bit.startswith("server_max_window_bits"): if "=" in bit: server_max_window_bits = int(bit.split("=", 1)[1].strip()) else: server_max_window_bits = self.server_max_window_bits return client_max_window_bits, server_max_window_bits def accept(self, offer: str) -> Union[bool, None, str]: client_max_window_bits, server_max_window_bits = self._parse_params(offer) parameters = [] if self.client_no_context_takeover: parameters.append("client_no_context_takeover") if self.server_no_context_takeover: parameters.append("server_no_context_takeover") try: if client_max_window_bits is not None: parameters.append("client_max_window_bits=%d" % client_max_window_bits) self.client_max_window_bits = client_max_window_bits if server_max_window_bits is not None: parameters.append("server_max_window_bits=%d" % server_max_window_bits) self.server_max_window_bits = server_max_window_bits except ValueError: return None else: self._enabled = True return "; ".join(parameters) def frame_inbound_header( self, proto: Union[FrameDecoder, FrameProtocol], opcode: Opcode, rsv: RsvBits, payload_length: int, ) -> Union[CloseReason, RsvBits]: if rsv.rsv1 and opcode.iscontrol(): return CloseReason.PROTOCOL_ERROR if rsv.rsv1 and opcode is Opcode.CONTINUATION: return CloseReason.PROTOCOL_ERROR self._inbound_is_compressible = self._compressible_opcode(opcode) if self._inbound_compressed is None: self._inbound_compressed = rsv.rsv1 if self._inbound_compressed: assert self._inbound_is_compressible if proto.client: bits = self.server_max_window_bits else: bits = self.client_max_window_bits if self._decompressor is None: self._decompressor = zlib.decompressobj(-int(bits)) return RsvBits(True, False, False) def frame_inbound_payload_data( self, proto: Union[FrameDecoder, FrameProtocol], data: bytes ) -> Union[bytes, CloseReason]: if not self._inbound_compressed or not self._inbound_is_compressible: return data assert self._decompressor is not None try: return self._decompressor.decompress(bytes(data)) except zlib.error: return CloseReason.INVALID_FRAME_PAYLOAD_DATA def frame_inbound_complete( self, proto: Union[FrameDecoder, FrameProtocol], fin: bool ) -> Union[bytes, CloseReason, None]: if not fin: return None if not self._inbound_is_compressible: self._inbound_compressed = None return None if not self._inbound_compressed: self._inbound_compressed = None return None assert self._decompressor is not None try: data = self._decompressor.decompress(b"\x00\x00\xff\xff") data += self._decompressor.flush() except zlib.error: return CloseReason.INVALID_FRAME_PAYLOAD_DATA if proto.client: no_context_takeover = self.server_no_context_takeover else: no_context_takeover = self.client_no_context_takeover if no_context_takeover: self._decompressor = None self._inbound_compressed = None return data def frame_outbound( self, proto: Union[FrameDecoder, FrameProtocol], opcode: Opcode, rsv: RsvBits, data: bytes, fin: bool, ) -> Tuple[RsvBits, bytes]: if not self._compressible_opcode(opcode): return (rsv, data) if opcode is not Opcode.CONTINUATION: rsv = RsvBits(True, *rsv[1:]) if self._compressor is None: assert opcode is not Opcode.CONTINUATION if proto.client: bits = self.client_max_window_bits else: bits = self.server_max_window_bits self._compressor = zlib.compressobj( zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -int(bits) ) data = self._compressor.compress(bytes(data)) if fin: data += self._compressor.flush(zlib.Z_SYNC_FLUSH) data = data[:-4] if proto.client: no_context_takeover = self.client_no_context_takeover else: no_context_takeover = self.server_no_context_takeover if no_context_takeover: self._compressor = None return (rsv, data) def __repr__(self) -> str: descr = ["client_max_window_bits=%d" % self.client_max_window_bits] if self.client_no_context_takeover: descr.append("client_no_context_takeover") descr.append("server_max_window_bits=%d" % self.server_max_window_bits) if self.server_no_context_takeover: descr.append("server_no_context_takeover") return "<{} {}>".format(self.__class__.__name__, "; ".join(descr)) #: SUPPORTED_EXTENSIONS maps all supported extension names to their class. #: This can be used to iterate all supported extensions of wsproto, instantiate #: new extensions based on their name, or check if a given extension is #: supported or not. SUPPORTED_EXTENSIONS = {PerMessageDeflate.name: PerMessageDeflate} ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/frame_protocol.py0000664000175000017500000005555100000000000022024 0ustar00ubuntuubuntu00000000000000""" wsproto/frame_protocol ~~~~~~~~~~~~~~~~~~~~~~ WebSocket frame protocol implementation. """ import os import struct from codecs import getincrementaldecoder, IncrementalDecoder from enum import IntEnum from typing import Generator, List, NamedTuple, Optional, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: from .extensions import Extension # pragma: no cover _XOR_TABLE = [bytes(a ^ b for a in range(256)) for b in range(256)] class XorMaskerSimple: def __init__(self, masking_key: bytes) -> None: self._masking_key = masking_key def process(self, data: bytes) -> bytes: if data: data_array = bytearray(data) a, b, c, d = (_XOR_TABLE[n] for n in self._masking_key) data_array[::4] = data_array[::4].translate(a) data_array[1::4] = data_array[1::4].translate(b) data_array[2::4] = data_array[2::4].translate(c) data_array[3::4] = data_array[3::4].translate(d) # Rotate the masking key so that the next usage continues # with the next key element, rather than restarting. key_rotation = len(data) % 4 self._masking_key = ( self._masking_key[key_rotation:] + self._masking_key[:key_rotation] ) return bytes(data_array) return data class XorMaskerNull: def process(self, data: bytes) -> bytes: return data # RFC6455, Section 5.2 - Base Framing Protocol # Payload length constants PAYLOAD_LENGTH_TWO_BYTE = 126 PAYLOAD_LENGTH_EIGHT_BYTE = 127 MAX_PAYLOAD_NORMAL = 125 MAX_PAYLOAD_TWO_BYTE = 2**16 - 1 MAX_PAYLOAD_EIGHT_BYTE = 2**64 - 1 MAX_FRAME_PAYLOAD = MAX_PAYLOAD_EIGHT_BYTE # MASK and PAYLOAD LEN are packed into a byte MASK_MASK = 0x80 PAYLOAD_LEN_MASK = 0x7F # FIN, RSV[123] and OPCODE are packed into a single byte FIN_MASK = 0x80 RSV1_MASK = 0x40 RSV2_MASK = 0x20 RSV3_MASK = 0x10 OPCODE_MASK = 0x0F class Opcode(IntEnum): """ RFC 6455, Section 5.2 - Base Framing Protocol """ #: Continuation frame CONTINUATION = 0x0 #: Text message TEXT = 0x1 #: Binary message BINARY = 0x2 #: Close frame CLOSE = 0x8 #: Ping frame PING = 0x9 #: Pong frame PONG = 0xA def iscontrol(self) -> bool: return bool(self & 0x08) class CloseReason(IntEnum): """ RFC 6455, Section 7.4.1 - Defined Status Codes """ #: indicates a normal closure, meaning that the purpose for #: which the connection was established has been fulfilled. NORMAL_CLOSURE = 1000 #: indicates that an endpoint is "going away", such as a server #: going down or a browser having navigated away from a page. GOING_AWAY = 1001 #: indicates that an endpoint is terminating the connection due #: to a protocol error. PROTOCOL_ERROR = 1002 #: indicates that an endpoint is terminating the connection #: because it has received a type of data it cannot accept (e.g., an #: endpoint that understands only text data MAY send this if it #: receives a binary message). UNSUPPORTED_DATA = 1003 #: Reserved. The specific meaning might be defined in the future. # DON'T DEFINE THIS: RESERVED_1004 = 1004 #: is a reserved value and MUST NOT be set as a status code in a #: Close control frame by an endpoint. It is designated for use in #: applications expecting a status code to indicate that no status #: code was actually present. NO_STATUS_RCVD = 1005 #: is a reserved value and MUST NOT be set as a status code in a #: Close control frame by an endpoint. It is designated for use in #: applications expecting a status code to indicate that the #: connection was closed abnormally, e.g., without sending or #: receiving a Close control frame. ABNORMAL_CLOSURE = 1006 #: indicates that an endpoint is terminating the connection #: because it has received data within a message that was not #: consistent with the type of the message (e.g., non-UTF-8 [RFC3629] #: data within a text message). INVALID_FRAME_PAYLOAD_DATA = 1007 #: indicates that an endpoint is terminating the connection #: because it has received a message that violates its policy. This #: is a generic status code that can be returned when there is no #: other more suitable status code (e.g., 1003 or 1009) or if there #: is a need to hide specific details about the policy. POLICY_VIOLATION = 1008 #: indicates that an endpoint is terminating the connection #: because it has received a message that is too big for it to #: process. MESSAGE_TOO_BIG = 1009 #: indicates that an endpoint (client) is terminating the #: connection because it has expected the server to negotiate one or #: more extension, but the server didn't return them in the response #: message of the WebSocket handshake. The list of extensions that #: are needed SHOULD appear in the /reason/ part of the Close frame. #: Note that this status code is not used by the server, because it #: can fail the WebSocket handshake instead. MANDATORY_EXT = 1010 #: indicates that a server is terminating the connection because #: it encountered an unexpected condition that prevented it from #: fulfilling the request. INTERNAL_ERROR = 1011 #: Server/service is restarting #: (not part of RFC6455) SERVICE_RESTART = 1012 #: Temporary server condition forced blocking client's request #: (not part of RFC6455) TRY_AGAIN_LATER = 1013 #: is a reserved value and MUST NOT be set as a status code in a #: Close control frame by an endpoint. It is designated for use in #: applications expecting a status code to indicate that the #: connection was closed due to a failure to perform a TLS handshake #: (e.g., the server certificate can't be verified). TLS_HANDSHAKE_FAILED = 1015 # RFC 6455, Section 7.4.1 - Defined Status Codes LOCAL_ONLY_CLOSE_REASONS = ( CloseReason.NO_STATUS_RCVD, CloseReason.ABNORMAL_CLOSURE, CloseReason.TLS_HANDSHAKE_FAILED, ) # RFC 6455, Section 7.4.2 - Status Code Ranges MIN_CLOSE_REASON = 1000 MIN_PROTOCOL_CLOSE_REASON = 1000 MAX_PROTOCOL_CLOSE_REASON = 2999 MIN_LIBRARY_CLOSE_REASON = 3000 MAX_LIBRARY_CLOSE_REASON = 3999 MIN_PRIVATE_CLOSE_REASON = 4000 MAX_PRIVATE_CLOSE_REASON = 4999 MAX_CLOSE_REASON = 4999 NULL_MASK = struct.pack("!I", 0) class ParseFailed(Exception): def __init__( self, msg: str, code: CloseReason = CloseReason.PROTOCOL_ERROR ) -> None: super().__init__(msg) self.code = code class RsvBits(NamedTuple): rsv1: bool rsv2: bool rsv3: bool class Header(NamedTuple): fin: bool rsv: RsvBits opcode: Opcode payload_len: int masking_key: Optional[bytes] class Frame(NamedTuple): opcode: Opcode payload: Union[bytes, str, Tuple[int, str]] frame_finished: bool message_finished: bool def _truncate_utf8(data: bytes, nbytes: int) -> bytes: if len(data) <= nbytes: return data # Truncate data = data[:nbytes] # But we might have cut a codepoint in half, in which case we want to # discard the partial character so the data is at least # well-formed. This is a little inefficient since it processes the # whole message twice when in theory we could just peek at the last # few characters, but since this is only used for close messages (max # length = 125 bytes) it really doesn't matter. data = data.decode("utf-8", errors="ignore").encode("utf-8") return data class Buffer: def __init__(self, initial_bytes: Optional[bytes] = None) -> None: self.buffer = bytearray() self.bytes_used = 0 if initial_bytes: self.feed(initial_bytes) def feed(self, new_bytes: bytes) -> None: self.buffer += new_bytes def consume_at_most(self, nbytes: int) -> bytes: if not nbytes: return bytearray() data = self.buffer[self.bytes_used : self.bytes_used + nbytes] self.bytes_used += len(data) return data def consume_exactly(self, nbytes: int) -> Optional[bytes]: if len(self.buffer) - self.bytes_used < nbytes: return None return self.consume_at_most(nbytes) def commit(self) -> None: # In CPython 3.4+, del[:n] is amortized O(n), *not* quadratic del self.buffer[: self.bytes_used] self.bytes_used = 0 def rollback(self) -> None: self.bytes_used = 0 def __len__(self) -> int: return len(self.buffer) class MessageDecoder: def __init__(self) -> None: self.opcode: Optional[Opcode] = None self.decoder: Optional[IncrementalDecoder] = None def process_frame(self, frame: Frame) -> Frame: assert not frame.opcode.iscontrol() if self.opcode is None: if frame.opcode is Opcode.CONTINUATION: raise ParseFailed("unexpected CONTINUATION") self.opcode = frame.opcode elif frame.opcode is not Opcode.CONTINUATION: raise ParseFailed("expected CONTINUATION, got %r" % frame.opcode) if frame.opcode is Opcode.TEXT: self.decoder = getincrementaldecoder("utf-8")() finished = frame.frame_finished and frame.message_finished if self.decoder is None: data = frame.payload else: assert isinstance(frame.payload, (bytes, bytearray)) try: data = self.decoder.decode(frame.payload, finished) except UnicodeDecodeError as exc: raise ParseFailed(str(exc), CloseReason.INVALID_FRAME_PAYLOAD_DATA) frame = Frame(self.opcode, data, frame.frame_finished, finished) if finished: self.opcode = None self.decoder = None return frame class FrameDecoder: def __init__( self, client: bool, extensions: Optional[List["Extension"]] = None ) -> None: self.client = client self.extensions = extensions or [] self.buffer = Buffer() self.header: Optional[Header] = None self.effective_opcode: Optional[Opcode] = None self.masker: Union[None, XorMaskerNull, XorMaskerSimple] = None self.payload_required = 0 self.payload_consumed = 0 def receive_bytes(self, data: bytes) -> None: self.buffer.feed(data) def process_buffer(self) -> Optional[Frame]: if not self.header: if not self.parse_header(): return None # parse_header() sets these. assert self.header is not None assert self.masker is not None assert self.effective_opcode is not None if len(self.buffer) < self.payload_required: return None payload_remaining = self.header.payload_len - self.payload_consumed payload = self.buffer.consume_at_most(payload_remaining) if not payload and self.header.payload_len > 0: return None self.buffer.commit() self.payload_consumed += len(payload) finished = self.payload_consumed == self.header.payload_len payload = self.masker.process(payload) for extension in self.extensions: payload_ = extension.frame_inbound_payload_data(self, payload) if isinstance(payload_, CloseReason): raise ParseFailed("error in extension", payload_) payload = payload_ if finished: final = bytearray() for extension in self.extensions: result = extension.frame_inbound_complete(self, self.header.fin) if isinstance(result, CloseReason): raise ParseFailed("error in extension", result) if result is not None: final += result payload += final frame = Frame(self.effective_opcode, payload, finished, self.header.fin) if finished: self.header = None self.effective_opcode = None self.masker = None else: self.effective_opcode = Opcode.CONTINUATION return frame def parse_header(self) -> bool: data = self.buffer.consume_exactly(2) if data is None: self.buffer.rollback() return False fin = bool(data[0] & FIN_MASK) rsv = RsvBits( bool(data[0] & RSV1_MASK), bool(data[0] & RSV2_MASK), bool(data[0] & RSV3_MASK), ) opcode = data[0] & OPCODE_MASK try: opcode = Opcode(opcode) except ValueError: raise ParseFailed(f"Invalid opcode {opcode:#x}") if opcode.iscontrol() and not fin: raise ParseFailed("Invalid attempt to fragment control frame") has_mask = bool(data[1] & MASK_MASK) payload_len_short = data[1] & PAYLOAD_LEN_MASK payload_len = self.parse_extended_payload_length(opcode, payload_len_short) if payload_len is None: self.buffer.rollback() return False self.extension_processing(opcode, rsv, payload_len) if has_mask and self.client: raise ParseFailed("client received unexpected masked frame") if not has_mask and not self.client: raise ParseFailed("server received unexpected unmasked frame") if has_mask: masking_key = self.buffer.consume_exactly(4) if masking_key is None: self.buffer.rollback() return False self.masker = XorMaskerSimple(masking_key) else: self.masker = XorMaskerNull() self.buffer.commit() self.header = Header(fin, rsv, opcode, payload_len, None) self.effective_opcode = self.header.opcode if self.header.opcode.iscontrol(): self.payload_required = payload_len else: self.payload_required = 0 self.payload_consumed = 0 return True def parse_extended_payload_length( self, opcode: Opcode, payload_len: int ) -> Optional[int]: if opcode.iscontrol() and payload_len > MAX_PAYLOAD_NORMAL: raise ParseFailed("Control frame with payload len > 125") if payload_len == PAYLOAD_LENGTH_TWO_BYTE: data = self.buffer.consume_exactly(2) if data is None: return None (payload_len,) = struct.unpack("!H", data) if payload_len <= MAX_PAYLOAD_NORMAL: raise ParseFailed( "Payload length used 2 bytes when 1 would have sufficed" ) elif payload_len == PAYLOAD_LENGTH_EIGHT_BYTE: data = self.buffer.consume_exactly(8) if data is None: return None (payload_len,) = struct.unpack("!Q", data) if payload_len <= MAX_PAYLOAD_TWO_BYTE: raise ParseFailed( "Payload length used 8 bytes when 2 would have sufficed" ) if payload_len >> 63: # I'm not sure why this is illegal, but that's what the RFC # says, so... raise ParseFailed("8-byte payload length with non-zero MSB") return payload_len def extension_processing( self, opcode: Opcode, rsv: RsvBits, payload_len: int ) -> None: rsv_used = [False, False, False] for extension in self.extensions: result = extension.frame_inbound_header(self, opcode, rsv, payload_len) if isinstance(result, CloseReason): raise ParseFailed("error in extension", result) for bit, used in enumerate(result): if used: rsv_used[bit] = True for expected, found in zip(rsv_used, rsv): if found and not expected: raise ParseFailed("Reserved bit set unexpectedly") class FrameProtocol: def __init__(self, client: bool, extensions: List["Extension"]) -> None: self.client = client self.extensions = [ext for ext in extensions if ext.enabled()] # Global state self._frame_decoder = FrameDecoder(self.client, self.extensions) self._message_decoder = MessageDecoder() self._parse_more = self._parse_more_gen() self._outbound_opcode: Optional[Opcode] = None def _process_close(self, frame: Frame) -> Frame: data = frame.payload assert isinstance(data, (bytes, bytearray)) if not data: # "If this Close control frame contains no status code, _The # WebSocket Connection Close Code_ is considered to be 1005" data = (CloseReason.NO_STATUS_RCVD, "") elif len(data) == 1: raise ParseFailed("CLOSE with 1 byte payload") else: (code,) = struct.unpack("!H", data[:2]) if code < MIN_CLOSE_REASON or code > MAX_CLOSE_REASON: raise ParseFailed("CLOSE with invalid code") try: code = CloseReason(code) except ValueError: pass if code in LOCAL_ONLY_CLOSE_REASONS: raise ParseFailed("remote CLOSE with local-only reason") if not isinstance(code, CloseReason) and code <= MAX_PROTOCOL_CLOSE_REASON: raise ParseFailed("CLOSE with unknown reserved code") try: reason = data[2:].decode("utf-8") except UnicodeDecodeError as exc: raise ParseFailed( "Error decoding CLOSE reason: " + str(exc), CloseReason.INVALID_FRAME_PAYLOAD_DATA, ) data = (code, reason) return Frame(frame.opcode, data, frame.frame_finished, frame.message_finished) def _parse_more_gen(self) -> Generator[Optional[Frame], None, None]: # Consume as much as we can from self._buffer, yielding events, and # then yield None when we need more data. Or raise ParseFailed. # XX FIXME this should probably be refactored so that we never see # disabled extensions in the first place... self.extensions = [ext for ext in self.extensions if ext.enabled()] closed = False while not closed: frame = self._frame_decoder.process_buffer() if frame is not None: if not frame.opcode.iscontrol(): frame = self._message_decoder.process_frame(frame) elif frame.opcode == Opcode.CLOSE: frame = self._process_close(frame) closed = True yield frame def receive_bytes(self, data: bytes) -> None: self._frame_decoder.receive_bytes(data) def received_frames(self) -> Generator[Frame, None, None]: for event in self._parse_more: if event is None: break else: yield event def close(self, code: Optional[int] = None, reason: Optional[str] = None) -> bytes: payload = bytearray() if code is CloseReason.NO_STATUS_RCVD: code = None if code is None and reason: raise TypeError("cannot specify a reason without a code") if code in LOCAL_ONLY_CLOSE_REASONS: code = CloseReason.NORMAL_CLOSURE if code is not None: payload += bytearray(struct.pack("!H", code)) if reason is not None: payload += _truncate_utf8( reason.encode("utf-8"), MAX_PAYLOAD_NORMAL - 2 ) return self._serialize_frame(Opcode.CLOSE, payload) def ping(self, payload: bytes = b"") -> bytes: return self._serialize_frame(Opcode.PING, payload) def pong(self, payload: bytes = b"") -> bytes: return self._serialize_frame(Opcode.PONG, payload) def send_data( self, payload: Union[bytes, bytearray, str] = b"", fin: bool = True ) -> bytes: if isinstance(payload, (bytes, bytearray, memoryview)): opcode = Opcode.BINARY elif isinstance(payload, str): opcode = Opcode.TEXT payload = payload.encode("utf-8") else: raise ValueError("Must provide bytes or text") if self._outbound_opcode is None: self._outbound_opcode = opcode elif self._outbound_opcode is not opcode: raise TypeError("Data type mismatch inside message") else: opcode = Opcode.CONTINUATION if fin: self._outbound_opcode = None return self._serialize_frame(opcode, payload, fin) def _make_fin_rsv_opcode(self, fin: bool, rsv: RsvBits, opcode: Opcode) -> int: fin_bits = int(fin) << 7 rsv_bits = (int(rsv.rsv1) << 6) + (int(rsv.rsv2) << 5) + (int(rsv.rsv3) << 4) opcode_bits = int(opcode) return fin_bits | rsv_bits | opcode_bits def _serialize_frame( self, opcode: Opcode, payload: bytes = b"", fin: bool = True ) -> bytes: rsv = RsvBits(False, False, False) for extension in reversed(self.extensions): rsv, payload = extension.frame_outbound(self, opcode, rsv, payload, fin) fin_rsv_opcode = self._make_fin_rsv_opcode(fin, rsv, opcode) payload_length = len(payload) quad_payload = False if payload_length <= MAX_PAYLOAD_NORMAL: first_payload = payload_length second_payload = None elif payload_length <= MAX_PAYLOAD_TWO_BYTE: first_payload = PAYLOAD_LENGTH_TWO_BYTE second_payload = payload_length else: first_payload = PAYLOAD_LENGTH_EIGHT_BYTE second_payload = payload_length quad_payload = True if self.client: first_payload |= 1 << 7 header = bytearray([fin_rsv_opcode, first_payload]) if second_payload is not None: if opcode.iscontrol(): raise ValueError("payload too long for control frame") if quad_payload: header += bytearray(struct.pack("!Q", second_payload)) else: header += bytearray(struct.pack("!H", second_payload)) if self.client: # "The masking key is a 32-bit value chosen at random by the # client. When preparing a masked frame, the client MUST pick a # fresh masking key from the set of allowed 32-bit values. The # masking key needs to be unpredictable; thus, the masking key # MUST be derived from a strong source of entropy, and the masking # key for a given frame MUST NOT make it simple for a server/proxy # to predict the masking key for a subsequent frame. The # unpredictability of the masking key is essential to prevent # authors of malicious applications from selecting the bytes that # appear on the wire." # -- https://tools.ietf.org/html/rfc6455#section-5.3 masking_key = os.urandom(4) masker = XorMaskerSimple(masking_key) return header + masking_key + masker.process(payload) return header + payload ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/handshake.py0000664000175000017500000004316400000000000020734 0ustar00ubuntuubuntu00000000000000""" wsproto/handshake ~~~~~~~~~~~~~~~~~~ An implementation of WebSocket handshakes. """ from collections import deque from typing import ( cast, Deque, Dict, Generator, Iterable, List, Optional, Sequence, Union, ) import h11 from .connection import Connection, ConnectionState, ConnectionType from .events import AcceptConnection, Event, RejectConnection, RejectData, Request from .extensions import Extension from .typing import Headers from .utilities import ( generate_accept_token, generate_nonce, LocalProtocolError, normed_header_dict, RemoteProtocolError, split_comma_header, ) # RFC6455, Section 4.2.1/6 - Reading the Client's Opening Handshake WEBSOCKET_VERSION = b"13" class H11Handshake: """A Handshake implementation for HTTP/1.1 connections.""" def __init__(self, connection_type: ConnectionType) -> None: self.client = connection_type is ConnectionType.CLIENT self._state = ConnectionState.CONNECTING if self.client: self._h11_connection = h11.Connection(h11.CLIENT) else: self._h11_connection = h11.Connection(h11.SERVER) self._connection: Optional[Connection] = None self._events: Deque[Event] = deque() self._initiating_request: Optional[Request] = None self._nonce: Optional[bytes] = None @property def state(self) -> ConnectionState: return self._state @property def connection(self) -> Optional[Connection]: """Return the established connection. This will either return the connection or raise a LocalProtocolError if the connection has not yet been established. :rtype: h11.Connection """ return self._connection def initiate_upgrade_connection( self, headers: Headers, path: Union[bytes, str] ) -> None: """Initiate an upgrade connection. This should be used if the request has already be received and parsed. :param list headers: HTTP headers represented as a list of 2-tuples. :param str path: A URL path. """ if self.client: raise LocalProtocolError( "Cannot initiate an upgrade connection when acting as the client" ) upgrade_request = h11.Request(method=b"GET", target=path, headers=headers) h11_client = h11.Connection(h11.CLIENT) self.receive_data(h11_client.send(upgrade_request)) def send(self, event: Event) -> bytes: """Send an event to the remote. This will return the bytes to send based on the event or raise a LocalProtocolError if the event is not valid given the state. :returns: Data to send to the WebSocket peer. :rtype: bytes """ data = b"" if isinstance(event, Request): data += self._initiate_connection(event) elif isinstance(event, AcceptConnection): data += self._accept(event) elif isinstance(event, RejectConnection): data += self._reject(event) elif isinstance(event, RejectData): data += self._send_reject_data(event) else: raise LocalProtocolError( f"Event {event} cannot be sent during the handshake" ) return data def receive_data(self, data: Optional[bytes]) -> None: """Receive data from the remote. A list of events that the remote peer triggered by sending this data can be retrieved with :meth:`events`. :param bytes data: Data received from the WebSocket peer. """ self._h11_connection.receive_data(data or b"") while True: try: event = self._h11_connection.next_event() except h11.RemoteProtocolError: raise RemoteProtocolError( "Bad HTTP message", event_hint=RejectConnection() ) if ( isinstance(event, h11.ConnectionClosed) or event is h11.NEED_DATA or event is h11.PAUSED ): break if self.client: if isinstance(event, h11.InformationalResponse): if event.status_code == 101: self._events.append(self._establish_client_connection(event)) else: self._events.append( RejectConnection( headers=list(event.headers), status_code=event.status_code, has_body=False, ) ) self._state = ConnectionState.CLOSED elif isinstance(event, h11.Response): self._state = ConnectionState.REJECTING self._events.append( RejectConnection( headers=list(event.headers), status_code=event.status_code, has_body=True, ) ) elif isinstance(event, h11.Data): self._events.append( RejectData(data=event.data, body_finished=False) ) elif isinstance(event, h11.EndOfMessage): self._events.append(RejectData(data=b"", body_finished=True)) self._state = ConnectionState.CLOSED else: if isinstance(event, h11.Request): self._events.append(self._process_connection_request(event)) def events(self) -> Generator[Event, None, None]: """Return a generator that provides any events that have been generated by protocol activity. :returns: a generator that yields H11 events. """ while self._events: yield self._events.popleft() # Server mode methods def _process_connection_request( # noqa: MC0001 self, event: h11.Request ) -> Request: if event.method != b"GET": raise RemoteProtocolError( "Request method must be GET", event_hint=RejectConnection() ) connection_tokens = None extensions: List[str] = [] host = None key = None subprotocols: List[str] = [] upgrade = b"" version = None headers: Headers = [] for name, value in event.headers: name = name.lower() if name == b"connection": connection_tokens = split_comma_header(value) elif name == b"host": host = value.decode("idna") continue # Skip appending to headers elif name == b"sec-websocket-extensions": extensions.extend(split_comma_header(value)) continue # Skip appending to headers elif name == b"sec-websocket-key": key = value elif name == b"sec-websocket-protocol": subprotocols.extend(split_comma_header(value)) continue # Skip appending to headers elif name == b"sec-websocket-version": version = value elif name == b"upgrade": upgrade = value headers.append((name, value)) if connection_tokens is None or not any( token.lower() == "upgrade" for token in connection_tokens ): raise RemoteProtocolError( "Missing header, 'Connection: Upgrade'", event_hint=RejectConnection() ) if version != WEBSOCKET_VERSION: raise RemoteProtocolError( "Missing header, 'Sec-WebSocket-Version'", event_hint=RejectConnection( headers=[(b"Sec-WebSocket-Version", WEBSOCKET_VERSION)], status_code=426 if version else 400, ), ) if key is None: raise RemoteProtocolError( "Missing header, 'Sec-WebSocket-Key'", event_hint=RejectConnection() ) if upgrade.lower() != b"websocket": raise RemoteProtocolError( "Missing header, 'Upgrade: WebSocket'", event_hint=RejectConnection() ) if host is None: raise RemoteProtocolError( "Missing header, 'Host'", event_hint=RejectConnection() ) self._initiating_request = Request( extensions=extensions, extra_headers=headers, host=host, subprotocols=subprotocols, target=event.target.decode("ascii"), ) return self._initiating_request def _accept(self, event: AcceptConnection) -> bytes: # _accept is always called after _process_connection_request. assert self._initiating_request is not None request_headers = normed_header_dict(self._initiating_request.extra_headers) nonce = request_headers[b"sec-websocket-key"] accept_token = generate_accept_token(nonce) headers = [ (b"Upgrade", b"WebSocket"), (b"Connection", b"Upgrade"), (b"Sec-WebSocket-Accept", accept_token), ] if event.subprotocol is not None: if event.subprotocol not in self._initiating_request.subprotocols: raise LocalProtocolError(f"unexpected subprotocol {event.subprotocol}") headers.append( (b"Sec-WebSocket-Protocol", event.subprotocol.encode("ascii")) ) if event.extensions: accepts = server_extensions_handshake( cast(Sequence[str], self._initiating_request.extensions), event.extensions, ) if accepts: headers.append((b"Sec-WebSocket-Extensions", accepts)) response = h11.InformationalResponse( status_code=101, headers=headers + event.extra_headers ) self._connection = Connection( ConnectionType.CLIENT if self.client else ConnectionType.SERVER, event.extensions, ) self._state = ConnectionState.OPEN return self._h11_connection.send(response) or b"" def _reject(self, event: RejectConnection) -> bytes: if self.state != ConnectionState.CONNECTING: raise LocalProtocolError( "Connection cannot be rejected in state %s" % self.state ) headers = list(event.headers) if not event.has_body: headers.append((b"content-length", b"0")) response = h11.Response(status_code=event.status_code, headers=headers) data = self._h11_connection.send(response) or b"" self._state = ConnectionState.REJECTING if not event.has_body: data += self._h11_connection.send(h11.EndOfMessage()) or b"" self._state = ConnectionState.CLOSED return data def _send_reject_data(self, event: RejectData) -> bytes: if self.state != ConnectionState.REJECTING: raise LocalProtocolError( f"Cannot send rejection data in state {self.state}" ) data = self._h11_connection.send(h11.Data(data=event.data)) or b"" if event.body_finished: data += self._h11_connection.send(h11.EndOfMessage()) or b"" self._state = ConnectionState.CLOSED return data # Client mode methods def _initiate_connection(self, request: Request) -> bytes: self._initiating_request = request self._nonce = generate_nonce() headers = [ (b"Host", request.host.encode("idna")), (b"Upgrade", b"WebSocket"), (b"Connection", b"Upgrade"), (b"Sec-WebSocket-Key", self._nonce), (b"Sec-WebSocket-Version", WEBSOCKET_VERSION), ] if request.subprotocols: headers.append( ( b"Sec-WebSocket-Protocol", (", ".join(request.subprotocols)).encode("ascii"), ) ) if request.extensions: offers: Dict[str, Union[str, bool]] = {} for e in request.extensions: assert isinstance(e, Extension) offers[e.name] = e.offer() extensions = [] for name, params in offers.items(): bname = name.encode("ascii") if isinstance(params, bool): if params: extensions.append(bname) else: extensions.append(b"%s; %s" % (bname, params.encode("ascii"))) if extensions: headers.append((b"Sec-WebSocket-Extensions", b", ".join(extensions))) upgrade = h11.Request( method=b"GET", target=request.target.encode("ascii"), headers=headers + request.extra_headers, ) return self._h11_connection.send(upgrade) or b"" def _establish_client_connection( self, event: h11.InformationalResponse ) -> AcceptConnection: # noqa: MC0001 # _establish_client_connection is always called after _initiate_connection. assert self._initiating_request is not None assert self._nonce is not None accept = None connection_tokens = None accepts: List[str] = [] subprotocol = None upgrade = b"" headers: Headers = [] for name, value in event.headers: name = name.lower() if name == b"connection": connection_tokens = split_comma_header(value) continue # Skip appending to headers elif name == b"sec-websocket-extensions": accepts = split_comma_header(value) continue # Skip appending to headers elif name == b"sec-websocket-accept": accept = value continue # Skip appending to headers elif name == b"sec-websocket-protocol": subprotocol = value.decode("ascii") continue # Skip appending to headers elif name == b"upgrade": upgrade = value continue # Skip appending to headers headers.append((name, value)) if connection_tokens is None or not any( token.lower() == "upgrade" for token in connection_tokens ): raise RemoteProtocolError( "Missing header, 'Connection: Upgrade'", event_hint=RejectConnection() ) if upgrade.lower() != b"websocket": raise RemoteProtocolError( "Missing header, 'Upgrade: WebSocket'", event_hint=RejectConnection() ) accept_token = generate_accept_token(self._nonce) if accept != accept_token: raise RemoteProtocolError("Bad accept token", event_hint=RejectConnection()) if subprotocol is not None: if subprotocol not in self._initiating_request.subprotocols: raise RemoteProtocolError( f"unrecognized subprotocol {subprotocol}", event_hint=RejectConnection(), ) extensions = client_extensions_handshake( accepts, cast(Sequence[Extension], self._initiating_request.extensions) ) self._connection = Connection( ConnectionType.CLIENT if self.client else ConnectionType.SERVER, extensions, self._h11_connection.trailing_data[0], ) self._state = ConnectionState.OPEN return AcceptConnection( extensions=extensions, extra_headers=headers, subprotocol=subprotocol ) def __repr__(self) -> str: return "{}(client={}, state={})".format( self.__class__.__name__, self.client, self.state ) def server_extensions_handshake( requested: Iterable[str], supported: List[Extension] ) -> Optional[bytes]: """Agree on the extensions to use returning an appropriate header value. This returns None if there are no agreed extensions """ accepts: Dict[str, Union[bool, bytes]] = {} for offer in requested: name = offer.split(";", 1)[0].strip() for extension in supported: if extension.name == name: accept = extension.accept(offer) if isinstance(accept, bool): if accept: accepts[extension.name] = True elif accept is not None: accepts[extension.name] = accept.encode("ascii") if accepts: extensions: List[bytes] = [] for name, params in accepts.items(): name_bytes = name.encode("ascii") if isinstance(params, bool): assert params extensions.append(name_bytes) else: if params == b"": extensions.append(b"%s" % (name_bytes)) else: extensions.append(b"%s; %s" % (name_bytes, params)) return b", ".join(extensions) return None def client_extensions_handshake( accepted: Iterable[str], supported: Sequence[Extension] ) -> List[Extension]: # This raises RemoteProtocolError is the accepted extension is not # supported. extensions = [] for accept in accepted: name = accept.split(";", 1)[0].strip() for extension in supported: if extension.name == name: extension.finalize(accept) extensions.append(extension) break else: raise RemoteProtocolError( f"unrecognized extension {name}", event_hint=RejectConnection() ) return extensions ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/py.typed0000664000175000017500000000000700000000000020120 0ustar00ubuntuubuntu00000000000000Marker ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/typing.py0000664000175000017500000000010400000000000020303 0ustar00ubuntuubuntu00000000000000from typing import List, Tuple Headers = List[Tuple[bytes, bytes]] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/src/wsproto/utilities.py0000664000175000017500000000540000000000000021010 0ustar00ubuntuubuntu00000000000000""" wsproto/utilities ~~~~~~~~~~~~~~~~~ Utility functions that do not belong in a separate module. """ import base64 import hashlib import os from typing import Dict, List, Optional, Union from h11._headers import Headers as H11Headers from .events import Event from .typing import Headers # RFC6455, Section 1.3 - Opening Handshake ACCEPT_GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" class ProtocolError(Exception): pass class LocalProtocolError(ProtocolError): """Indicates an error due to local/programming errors. This is raised when the connection is asked to do something that is either incompatible with the state or the websocket standard. """ pass # noqa class RemoteProtocolError(ProtocolError): """Indicates an error due to the remote's actions. This is raised when processing the bytes from the remote if the remote has sent data that is incompatible with the websocket standard. .. attribute:: event_hint This is a suggested wsproto Event to send to the client based on the error. It could be None if no hint is available. """ def __init__(self, message: str, event_hint: Optional[Event] = None) -> None: self.event_hint = event_hint super().__init__(message) # Some convenience utilities for working with HTTP headers def normed_header_dict(h11_headers: Union[Headers, H11Headers]) -> Dict[bytes, bytes]: # This mangles Set-Cookie headers. But it happens that we don't care about # any of those, so it's OK. For every other HTTP header, if there are # multiple instances then you're allowed to join them together with # commas. name_to_values: Dict[bytes, List[bytes]] = {} for name, value in h11_headers: name_to_values.setdefault(name, []).append(value) name_to_normed_value = {} for name, values in name_to_values.items(): name_to_normed_value[name] = b", ".join(values) return name_to_normed_value # We use this for parsing the proposed protocol list, and for parsing the # proposed and accepted extension lists. For the proposed protocol list it's # fine, because the ABNF is just 1#token. But for the extension lists, it's # wrong, because those can contain quoted strings, which can in turn contain # commas. XX FIXME def split_comma_header(value: bytes) -> List[str]: return [piece.decode("ascii").strip() for piece in value.split(b",")] def generate_nonce() -> bytes: # os.urandom may be overkill for this use case, but I don't think this # is a bottleneck, and better safe than sorry... return base64.b64encode(os.urandom(16)) def generate_accept_token(token: bytes) -> bytes: accept_token = token + ACCEPT_GUID accept_token = hashlib.sha1(accept_token).digest() return base64.b64encode(accept_token) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4622188 wsproto-1.2.0/src/wsproto.egg-info/0000775000175000017500000000000000000000000020116 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284695.0 wsproto-1.2.0/src/wsproto.egg-info/PKG-INFO0000664000175000017500000001270700000000000021222 0ustar00ubuntuubuntu00000000000000Metadata-Version: 2.1 Name: wsproto Version: 1.2.0 Summary: WebSockets state-machine based protocol implementation Home-page: https://github.com/python-hyper/wsproto/ Author: Benno Rice Author-email: benno@jeamland.net License: MIT License Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=3.7.0 Description-Content-Type: text/x-rst License-File: LICENSE ======================================================== Pure Python, pure state-machine WebSocket implementation ======================================================== .. image:: https://github.com/python-hyper/wsproto/workflows/CI/badge.svg :target: https://github.com/python-hyper/wsproto/actions :alt: Build Status .. image:: https://codecov.io/gh/python-hyper/wsproto/branch/main/graph/badge.svg :target: https://codecov.io/gh/python-hyper/wsproto :alt: Code Coverage .. image:: https://readthedocs.org/projects/wsproto/badge/?version=latest :target: https://wsproto.readthedocs.io/en/latest/ :alt: Documentation Status .. image:: https://img.shields.io/badge/chat-join_now-brightgreen.svg :target: https://gitter.im/python-hyper/community :alt: Chat community This repository contains a pure-Python implementation of a WebSocket protocol stack. It's written from the ground up to be embeddable in whatever program you choose to use, ensuring that you can communicate via WebSockets, as defined in `RFC6455 `_, regardless of your programming paradigm. This repository does not provide a parsing layer, a network layer, or any rules about concurrency. Instead, it's a purely in-memory solution, defined in terms of data actions and WebSocket frames. RFC6455 and Compression Extensions for WebSocket via `RFC7692 `_ are fully supported. wsproto supports Python 3.6.1 or higher. To install it, just run: .. code-block:: console $ pip install wsproto Usage ===== Let's assume you have some form of network socket available. wsproto client connections automatically generate a HTTP request to initiate the WebSocket handshake. To create a WebSocket client connection: .. code-block:: python from wsproto import WSConnection, ConnectionType from wsproto.events import Request ws = WSConnection(ConnectionType.CLIENT) ws.send(Request(host='echo.websocket.org', target='/')) To create a WebSocket server connection: .. code-block:: python from wsproto.connection import WSConnection, ConnectionType ws = WSConnection(ConnectionType.SERVER) Every time you send a message, or call a ping, or simply if you receive incoming data, wsproto might respond with some outgoing data that you have to send: .. code-block:: python some_socket.send(ws.bytes_to_send()) Both connection types need to receive incoming data: .. code-block:: python ws.receive_data(some_byte_string_of_data) And wsproto will issue events if the data contains any WebSocket messages or state changes: .. code-block:: python for event in ws.events(): if isinstance(event, Request): # only client connections get this event ws.send(AcceptConnection()) elif isinstance(event, CloseConnection): # guess nobody wants to talk to us any more... elif isinstance(event, TextMessage): print('We got text!', event.data) elif isinstance(event, BytesMessage): print('We got bytes!', event.data) Take a look at our docs for a `full list of events `! Testing ======= It passes the autobahn test suite completely and strictly in both client and server modes and using permessage-deflate. If you want to run the compliance tests, go into the compliance directory and then to test client mode, in one shell run the Autobahn test server: .. code-block:: console $ wstest -m fuzzingserver -s ws-fuzzingserver.json And in another shell run the test client: .. code-block:: console $ python test_client.py And to test server mode, run the test server: .. code-block:: console $ python test_server.py And in another shell run the Autobahn test client: .. code-block:: console $ wstest -m fuzzingclient -s ws-fuzzingclient.json Documentation ============= Documentation is available at https://wsproto.readthedocs.io/en/latest/. Contributing ============ ``wsproto`` welcomes contributions from anyone! Unlike many other projects we are happy to accept cosmetic contributions and small contributions, in addition to large feature requests and changes. Before you contribute (either by opening an issue or filing a pull request), please `read the contribution guidelines`_. .. _read the contribution guidelines: http://python-hyper.org/en/latest/contributing.html License ======= ``wsproto`` is made available under the MIT License. For more details, see the ``LICENSE`` file in the repository. Authors ======= ``wsproto`` was created by @jeamland, and is maintained by the python-hyper community. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284695.0 wsproto-1.2.0/src/wsproto.egg-info/SOURCES.txt0000664000175000017500000000212100000000000021776 0ustar00ubuntuubuntu00000000000000CHANGELOG.rst LICENSE MANIFEST.in README.rst setup.cfg setup.py tox.ini bench/connection.py compliance/run-autobahn-tests.py compliance/test_client.py compliance/test_server.py compliance/ws-fuzzingclient.json compliance/ws-fuzzingserver.json docs/Makefile docs/make.bat docs/source/advanced-usage.rst docs/source/api.rst docs/source/basic-usage.rst docs/source/conf.py docs/source/index.rst docs/source/installation.rst docs/source/_static/.keep example/synchronous_client.py example/synchronous_server.py src/wsproto/__init__.py src/wsproto/connection.py src/wsproto/events.py src/wsproto/extensions.py src/wsproto/frame_protocol.py src/wsproto/handshake.py src/wsproto/py.typed src/wsproto/typing.py src/wsproto/utilities.py src/wsproto.egg-info/PKG-INFO src/wsproto.egg-info/SOURCES.txt src/wsproto.egg-info/dependency_links.txt src/wsproto.egg-info/requires.txt src/wsproto.egg-info/top_level.txt test/__init__.py test/helpers.py test/test_client.py test/test_connection.py test/test_extensions.py test/test_frame_protocol.py test/test_handshake.py test/test_permessage_deflate.py test/test_server.py././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284695.0 wsproto-1.2.0/src/wsproto.egg-info/dependency_links.txt0000664000175000017500000000000100000000000024164 0ustar00ubuntuubuntu00000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284695.0 wsproto-1.2.0/src/wsproto.egg-info/requires.txt0000664000175000017500000000001600000000000022513 0ustar00ubuntuubuntu00000000000000h11<1,>=0.9.0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284695.0 wsproto-1.2.0/src/wsproto.egg-info/top_level.txt0000664000175000017500000000001000000000000022637 0ustar00ubuntuubuntu00000000000000wsproto ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1661284695.4622188 wsproto-1.2.0/test/0000775000175000017500000000000000000000000015077 5ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/__init__.py0000664000175000017500000000000000000000000017176 0ustar00ubuntuubuntu00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/helpers.py0000664000175000017500000000152600000000000017117 0ustar00ubuntuubuntu00000000000000from typing import Optional, Union from wsproto.extensions import Extension class FakeExtension(Extension): name = "fake" def __init__( self, offer_response: Optional[Union[bool, str]] = None, accept_response: Optional[Union[bool, str]] = None, ) -> None: self.offer_response = offer_response self.accepted_offer: Optional[str] = None self.offered: Optional[str] = None self.accept_response = accept_response def offer(self) -> Union[bool, str]: assert self.offer_response is not None return self.offer_response def finalize(self, offer: str) -> None: self.accepted_offer = offer def accept(self, offer: str) -> Union[bool, str]: assert self.accept_response is not None self.offered = offer return self.accept_response ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_client.py0000664000175000017500000002410000000000000017763 0ustar00ubuntuubuntu00000000000000from typing import cast, List, Optional import h11 import pytest from wsproto import WSConnection from wsproto.connection import CLIENT, ConnectionState from wsproto.events import ( AcceptConnection, Event, RejectConnection, RejectData, Request, ) from wsproto.extensions import Extension from wsproto.typing import Headers from wsproto.utilities import ( generate_accept_token, LocalProtocolError, normed_header_dict, RemoteProtocolError, ) from .helpers import FakeExtension def _make_connection_request(request: Request) -> h11.Request: client = WSConnection(CLIENT) server = h11.Connection(h11.SERVER) server.receive_data(client.send(request)) return cast(h11.Request, server.next_event()) def test_connection_request() -> None: request = _make_connection_request(Request(host="localhost", target="/")) assert request.http_version == b"1.1" assert request.method == b"GET" assert request.target == b"/" headers = normed_header_dict(request.headers) assert headers[b"connection"] == b"Upgrade" assert headers[b"host"] == b"localhost" assert headers[b"sec-websocket-version"] == b"13" assert headers[b"upgrade"] == b"WebSocket" assert b"sec-websocket-key" in headers def test_connection_request_additional_headers() -> None: request = _make_connection_request( Request( host="localhost", target="/", extra_headers=[(b"X-Foo", b"Bar"), (b"X-Bar", b"Foo")], ) ) headers = normed_header_dict(request.headers) assert headers[b"x-foo"] == b"Bar" assert headers[b"x-bar"] == b"Foo" def test_connection_request_simple_extension() -> None: extension = FakeExtension(offer_response=True) request = _make_connection_request( Request(host="localhost", target="/", extensions=[extension]) ) headers = normed_header_dict(request.headers) assert headers[b"sec-websocket-extensions"] == extension.name.encode("ascii") def test_connection_request_simple_extension_no_offer() -> None: extension = FakeExtension(offer_response=False) request = _make_connection_request( Request(host="localhost", target="/", extensions=[extension]) ) headers = normed_header_dict(request.headers) assert b"sec-websocket-extensions" not in headers def test_connection_request_parametrised_extension() -> None: offer_response = "parameter1=value1; parameter2=value2" extension = FakeExtension(offer_response=offer_response) request = _make_connection_request( Request(host="localhost", target="/", extensions=[extension]) ) headers = normed_header_dict(request.headers) assert headers[b"sec-websocket-extensions"] == b"%s; %s" % ( extension.name.encode("ascii"), offer_response.encode("ascii"), ) def test_connection_request_subprotocols() -> None: request = _make_connection_request( Request(host="localhost", target="/", subprotocols=["one", "two"]) ) headers = normed_header_dict(request.headers) assert headers[b"sec-websocket-protocol"] == b"one, two" def test_connection_send_state() -> None: client = WSConnection(CLIENT) assert client.state is ConnectionState.CONNECTING server = h11.Connection(h11.SERVER) server.receive_data( client.send( Request( host="localhost", target="/", ) ) ) headers = normed_header_dict(cast(h11.Request, server.next_event()).headers) response = h11.InformationalResponse( status_code=101, headers=[ (b"connection", b"Upgrade"), (b"upgrade", b"WebSocket"), ( b"Sec-WebSocket-Accept", generate_accept_token(headers[b"sec-websocket-key"]), ), ], ) client.receive_data(server.send(response)) assert len(list(client.events())) == 1 assert client.state is ConnectionState.OPEN # type: ignore # https://github.com/python/mypy/issues/9005 with pytest.raises(LocalProtocolError): client.send(Request(host="localhost", target="/")) client.receive_data(b"foobar") assert len(list(client.events())) == 1 def _make_handshake( response_status: int, response_headers: Headers, subprotocols: Optional[List[str]] = None, extensions: Optional[List[Extension]] = None, auto_accept_key: bool = True, ) -> List[Event]: client = WSConnection(CLIENT) assert client.state is ConnectionState.CONNECTING server = h11.Connection(h11.SERVER) server.receive_data( client.send( Request( host="localhost", target="/", subprotocols=subprotocols or [], extensions=extensions or [], ) ) ) request = cast(h11.Request, server.next_event()) if auto_accept_key: full_request_headers = normed_header_dict(request.headers) response_headers.append( ( b"Sec-WebSocket-Accept", generate_accept_token(full_request_headers[b"sec-websocket-key"]), ) ) response = h11.InformationalResponse( status_code=response_status, headers=response_headers ) client.receive_data(server.send(response)) assert client.state is not ConnectionState.CONNECTING return list(client.events()) def test_handshake() -> None: events = _make_handshake( 101, [(b"connection", b"Upgrade"), (b"upgrade", b"WebSocket")] ) assert events == [AcceptConnection()] def test_broken_handshake() -> None: events = _make_handshake( 102, [(b"connection", b"Upgrade"), (b"upgrade", b"WebSocket")] ) assert isinstance(events[0], RejectConnection) assert events[0].status_code == 102 def test_handshake_extra_accept_headers() -> None: events = _make_handshake( 101, [(b"connection", b"Upgrade"), (b"upgrade", b"WebSocket"), (b"X-Foo", b"bar")], ) assert events == [AcceptConnection(extra_headers=[(b"x-foo", b"bar")])] @pytest.mark.parametrize("extra_headers", [[], [(b"connection", b"Keep-Alive")]]) def test_handshake_response_broken_connection_header(extra_headers: Headers) -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_handshake(101, [(b"upgrade", b"WebSocket")] + extra_headers) assert str(excinfo.value) == "Missing header, 'Connection: Upgrade'" @pytest.mark.parametrize("extra_headers", [[], [(b"upgrade", b"h2")]]) def test_handshake_response_broken_upgrade_header(extra_headers: Headers) -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_handshake(101, [(b"connection", b"Upgrade")] + extra_headers) assert str(excinfo.value) == "Missing header, 'Upgrade: WebSocket'" def test_handshake_response_missing_websocket_key_header() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_handshake( 101, [(b"connection", b"Upgrade"), (b"upgrade", b"WebSocket")], auto_accept_key=False, ) assert str(excinfo.value) == "Bad accept token" def test_handshake_with_subprotocol() -> None: events = _make_handshake( 101, [ (b"connection", b"Upgrade"), (b"upgrade", b"WebSocket"), (b"sec-websocket-protocol", b"one"), ], subprotocols=["one", "two"], ) assert events == [AcceptConnection(subprotocol="one")] def test_handshake_bad_subprotocol() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_handshake( 101, [ (b"connection", b"Upgrade"), (b"upgrade", b"WebSocket"), (b"sec-websocket-protocol", b"new"), ], ) assert str(excinfo.value) == "unrecognized subprotocol new" def test_handshake_with_extension() -> None: extension = FakeExtension(offer_response=True) events = _make_handshake( 101, [ (b"connection", b"Upgrade"), (b"upgrade", b"WebSocket"), (b"sec-websocket-extensions", b"fake"), ], extensions=[extension], ) assert events == [AcceptConnection(extensions=[extension])] def test_handshake_bad_extension() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_handshake( 101, [ (b"connection", b"Upgrade"), (b"upgrade", b"WebSocket"), (b"sec-websocket-extensions", b"bad, foo"), ], ) assert str(excinfo.value) == "unrecognized extension bad" def test_protocol_error() -> None: client = WSConnection(CLIENT) client.send(Request(host="localhost", target="/")) with pytest.raises(RemoteProtocolError) as excinfo: client.receive_data(b"broken nonsense\r\n\r\n") assert str(excinfo.value) == "Bad HTTP message" def _make_handshake_rejection( status_code: int, body: Optional[bytes] = None ) -> List[Event]: client = WSConnection(CLIENT) server = h11.Connection(h11.SERVER) server.receive_data(client.send(Request(host="localhost", target="/"))) headers = [] if body is not None: headers.append(("Content-Length", str(len(body)))) client.receive_data( server.send(h11.Response(status_code=status_code, headers=headers)) ) if body is not None: client.receive_data(server.send(h11.Data(data=body))) client.receive_data(server.send(h11.EndOfMessage())) return list(client.events()) def test_handshake_rejection() -> None: events = _make_handshake_rejection(400) assert events == [ RejectConnection( headers=[(b"connection", b"close")], has_body=True, status_code=400 ), RejectData(body_finished=True, data=b""), ] def test_handshake_rejection_with_body() -> None: events = _make_handshake_rejection(400, b"Hello") assert events == [ RejectConnection( headers=[(b"content-length", b"5")], has_body=True, status_code=400 ), RejectData(body_finished=False, data=b"Hello"), RejectData(body_finished=True, data=b""), ] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_connection.py0000664000175000017500000001162400000000000020653 0ustar00ubuntuubuntu00000000000000import pytest from wsproto.connection import CLIENT, Connection, ConnectionState, SERVER from wsproto.events import ( BytesMessage, CloseConnection, Ping, Pong, Request, TextMessage, ) from wsproto.frame_protocol import CloseReason from wsproto.utilities import LocalProtocolError @pytest.mark.parametrize("client_sends", [True, False]) @pytest.mark.parametrize("final", [True, False]) def test_send_message(client_sends: bool, final: bool) -> None: client = Connection(CLIENT) server = Connection(SERVER) if client_sends: local = client remote = server else: local = server remote = client data = b"x" * 23 remote.receive_data(local.send(BytesMessage(data=data, message_finished=final))) event = next(remote.events()) assert isinstance(event, BytesMessage) assert event.data == data assert event.message_finished is final @pytest.mark.parametrize("client_sends", [True, False]) @pytest.mark.parametrize( "code, reason", [(CloseReason.NORMAL_CLOSURE, "bye"), (CloseReason.GOING_AWAY, "👋👋")], ) def test_closure(client_sends: bool, code: CloseReason, reason: str) -> None: client = Connection(CLIENT) server = Connection(SERVER) if client_sends: local = client remote = server else: local = server remote = client remote.receive_data(local.send(CloseConnection(code=code, reason=reason))) event = next(remote.events()) assert isinstance(event, CloseConnection) assert event.code is code assert event.reason == reason assert remote.state is ConnectionState.REMOTE_CLOSING assert local.state is ConnectionState.LOCAL_CLOSING local.receive_data(remote.send(event.response())) event = next(local.events()) assert isinstance(event, CloseConnection) assert event.code is code assert event.reason == reason assert remote.state is ConnectionState.CLOSED # type: ignore[comparison-overlap] assert local.state is ConnectionState.CLOSED with pytest.raises(LocalProtocolError): local.receive_data(b"foobar") def test_abnormal_closure() -> None: client = Connection(CLIENT) client.receive_data(None) event = next(client.events()) assert isinstance(event, CloseConnection) assert event.code is CloseReason.ABNORMAL_CLOSURE assert client.state is ConnectionState.CLOSED def test_close_whilst_closing() -> None: client = Connection(CLIENT) client.send(CloseConnection(code=CloseReason.NORMAL_CLOSURE)) with pytest.raises(LocalProtocolError): client.send(CloseConnection(code=CloseReason.NORMAL_CLOSURE)) def test_send_after_close() -> None: client = Connection(CLIENT) client.send(CloseConnection(code=CloseReason.NORMAL_CLOSURE)) with pytest.raises(LocalProtocolError): client.send(TextMessage(data="", message_finished=True)) @pytest.mark.parametrize("client_sends", [True, False]) def test_ping_pong(client_sends: bool) -> None: client = Connection(CLIENT) server = Connection(SERVER) if client_sends: local = client remote = server else: local = server remote = client payload = b"x" * 23 remote.receive_data(local.send(Ping(payload=payload))) event = next(remote.events()) assert isinstance(event, Ping) assert event.payload == payload local.receive_data(remote.send(event.response())) event = next(local.events()) assert isinstance(event, Pong) assert event.payload == payload def test_unsolicited_pong() -> None: client = Connection(CLIENT) server = Connection(SERVER) payload = b"x" * 23 server.receive_data(client.send(Pong(payload=payload))) event = next(server.events()) assert isinstance(event, Pong) assert event.payload == payload @pytest.mark.parametrize("split_message", [True, False]) def test_data(split_message: bool) -> None: client = Connection(CLIENT) server = Connection(SERVER) data = "ƒñö®∂😎" server.receive_data( client.send(TextMessage(data=data, message_finished=not split_message)) ) event = next(server.events()) assert isinstance(event, TextMessage) assert event.message_finished is not split_message def test_frame_protocol_gets_fed_garbage() -> None: client = Connection(CLIENT) payload = b"x" * 23 frame = b"\x09" + bytearray([len(payload)]) + payload client.receive_data(frame) event = next(client.events()) assert isinstance(event, CloseConnection) assert event.code == CloseReason.PROTOCOL_ERROR def test_send_invalid_event() -> None: client = Connection(CLIENT) with pytest.raises(LocalProtocolError): client.send(Request(target="/", host="wsproto")) def test_receive_data_when_closed() -> None: client = Connection(CLIENT) client._state = ConnectionState.CLOSED with pytest.raises(LocalProtocolError): client.receive_data(b"something") ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_extensions.py0000664000175000017500000000261600000000000020714 0ustar00ubuntuubuntu00000000000000from wsproto import extensions as wpext, frame_protocol as fp class TestExtension: def test_enabled(self) -> None: ext = wpext.Extension() assert not ext.enabled() def test_offer(self) -> None: ext = wpext.Extension() assert ext.offer() is None def test_accept(self) -> None: ext = wpext.Extension() offer = "myext" assert ext.accept(offer) is None def test_finalize(self) -> None: ext = wpext.Extension() offer = "myext" ext.finalize(offer) def test_frame_inbound_header(self) -> None: ext = wpext.Extension() result = ext.frame_inbound_header(None, None, None, None) # type: ignore[arg-type] assert result == fp.RsvBits(False, False, False) def test_frame_inbound_payload_data(self) -> None: ext = wpext.Extension() data = b"" assert ext.frame_inbound_payload_data(None, data) == data # type: ignore[arg-type] def test_frame_inbound_complete(self) -> None: ext = wpext.Extension() assert ext.frame_inbound_complete(None, None) is None # type: ignore[arg-type] def test_frame_outbound(self) -> None: ext = wpext.Extension() rsv = fp.RsvBits(True, True, True) data = b"" assert ext.frame_outbound(None, None, rsv, data, None) == ( # type: ignore[arg-type] rsv, data, ) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_frame_protocol.py0000664000175000017500000012446600000000000021540 0ustar00ubuntuubuntu00000000000000import itertools import struct from binascii import unhexlify from codecs import getincrementaldecoder from typing import Dict, Optional, Tuple, Union import pytest from wsproto import extensions as wpext, frame_protocol as fp class TestBuffer: def test_consume_at_most_zero_bytes(self) -> None: buf = fp.Buffer(b"xxyyy") assert buf.consume_at_most(0) == bytearray() def test_consume_at_most_with_no_data(self) -> None: buf = fp.Buffer() assert buf.consume_at_most(1) == bytearray() def test_consume_at_most_with_sufficient_data(self) -> None: buf = fp.Buffer(b"xx") assert buf.consume_at_most(2) == b"xx" def test_consume_at_most_with_more_than_sufficient_data(self) -> None: buf = fp.Buffer(b"xxyyy") assert buf.consume_at_most(2) == b"xx" def test_consume_at_most_with_insufficient_data(self) -> None: buf = fp.Buffer(b"xx") assert buf.consume_at_most(3) == b"xx" def test_consume_exactly_with_sufficient_data(self) -> None: buf = fp.Buffer(b"xx") assert buf.consume_exactly(2) == b"xx" def test_consume_exactly_with_more_than_sufficient_data(self) -> None: buf = fp.Buffer(b"xxyyy") assert buf.consume_exactly(2) == b"xx" def test_consume_exactly_with_insufficient_data(self) -> None: buf = fp.Buffer(b"xx") assert buf.consume_exactly(3) is None def test_feed(self) -> None: buf = fp.Buffer() assert buf.consume_at_most(1) == b"" assert buf.consume_exactly(1) is None buf.feed(b"xy") assert buf.consume_at_most(1) == b"x" assert buf.consume_exactly(1) == b"y" def test_rollback(self) -> None: buf = fp.Buffer() buf.feed(b"xyz") assert buf.consume_exactly(2) == b"xy" assert buf.consume_exactly(1) == b"z" assert buf.consume_at_most(1) == b"" buf.rollback() assert buf.consume_at_most(3) == b"xyz" def test_commit(self) -> None: buf = fp.Buffer() buf.feed(b"xyz") assert buf.consume_exactly(2) == b"xy" assert buf.consume_exactly(1) == b"z" assert buf.consume_at_most(1) == b"" buf.commit() assert buf.consume_at_most(3) == b"" def test_length(self) -> None: buf = fp.Buffer() data = b"xyzabc" buf.feed(data) assert len(buf) == len(data) class TestMessageDecoder: def test_single_binary_frame(self) -> None: payload = b"x" * 23 decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.BINARY, payload=payload, frame_finished=True, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.BINARY assert frame.message_finished is True assert frame.payload == payload def test_follow_on_binary_frame(self) -> None: payload = b"x" * 23 decoder = fp.MessageDecoder() decoder.opcode = fp.Opcode.BINARY frame = fp.Frame( opcode=fp.Opcode.CONTINUATION, payload=payload, frame_finished=True, message_finished=False, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.BINARY assert frame.message_finished is False assert frame.payload == payload def test_single_text_frame(self) -> None: text_payload = "fñör∂" binary_payload = text_payload.encode("utf8") decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=binary_payload, frame_finished=True, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is True assert frame.payload == text_payload def test_follow_on_text_frame(self) -> None: text_payload = "fñör∂" binary_payload = text_payload.encode("utf8") decoder = fp.MessageDecoder() decoder.opcode = fp.Opcode.TEXT decoder.decoder = getincrementaldecoder("utf-8")() assert decoder.decoder.decode(binary_payload[:4]) == text_payload[:2] binary_payload = binary_payload[4:-2] text_payload = text_payload[2:-1] frame = fp.Frame( opcode=fp.Opcode.CONTINUATION, payload=binary_payload, frame_finished=True, message_finished=False, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is False assert frame.payload == text_payload def test_final_text_frame(self) -> None: text_payload = "fñör∂" binary_payload = text_payload.encode("utf8") decoder = fp.MessageDecoder() decoder.opcode = fp.Opcode.TEXT decoder.decoder = getincrementaldecoder("utf-8")() assert decoder.decoder.decode(binary_payload[:-2]) == text_payload[:-1] binary_payload = binary_payload[-2:] text_payload = text_payload[-1:] frame = fp.Frame( opcode=fp.Opcode.CONTINUATION, payload=binary_payload, frame_finished=True, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is True assert frame.payload == text_payload def test_start_with_continuation(self) -> None: payload = b"x" * 23 decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.CONTINUATION, payload=payload, frame_finished=True, message_finished=True, ) with pytest.raises(fp.ParseFailed): decoder.process_frame(frame) def test_missing_continuation_1(self) -> None: payload = b"x" * 23 decoder = fp.MessageDecoder() decoder.opcode = fp.Opcode.BINARY frame = fp.Frame( opcode=fp.Opcode.BINARY, payload=payload, frame_finished=True, message_finished=True, ) with pytest.raises(fp.ParseFailed): decoder.process_frame(frame) def test_missing_continuation_2(self) -> None: payload = b"x" * 23 decoder = fp.MessageDecoder() decoder.opcode = fp.Opcode.TEXT frame = fp.Frame( opcode=fp.Opcode.BINARY, payload=payload, frame_finished=True, message_finished=True, ) with pytest.raises(fp.ParseFailed): decoder.process_frame(frame) def test_incomplete_unicode(self) -> None: payload = "fñör∂".encode() payload = payload[:4] decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, ) with pytest.raises(fp.ParseFailed) as excinfo: decoder.process_frame(frame) assert excinfo.value.code is fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA def test_not_even_unicode(self) -> None: payload = "fñörd".encode("iso-8859-1") decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=False, ) with pytest.raises(fp.ParseFailed) as excinfo: decoder.process_frame(frame) assert excinfo.value.code is fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA def test_bad_unicode(self) -> None: payload = unhexlify("cebae1bdb9cf83cebcceb5eda080656469746564") decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, ) with pytest.raises(fp.ParseFailed) as excinfo: decoder.process_frame(frame) assert excinfo.value.code is fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA def test_split_message(self) -> None: text_payload = "x" * 65535 payload = text_payload.encode("utf-8") split = 32777 decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=payload[:split], frame_finished=False, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is False assert frame.payload == text_payload[:split] frame = fp.Frame( opcode=fp.Opcode.CONTINUATION, payload=payload[split:], frame_finished=True, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is True assert frame.payload == text_payload[split:] def test_split_unicode_message(self) -> None: text_payload = "∂" * 64 payload = text_payload.encode("utf-8") split = 64 decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=payload[:split], frame_finished=False, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is False assert frame.payload == text_payload[: (split // 3)] frame = fp.Frame( opcode=fp.Opcode.CONTINUATION, payload=payload[split:], frame_finished=True, message_finished=True, ) frame = decoder.process_frame(frame) assert frame.opcode is fp.Opcode.TEXT assert frame.message_finished is True assert frame.payload == text_payload[(split // 3) :] def send_frame_to_validator(self, payload: bytes, finished: bool) -> None: decoder = fp.MessageDecoder() frame = fp.Frame( opcode=fp.Opcode.TEXT, payload=payload, frame_finished=finished, message_finished=True, ) frame = decoder.process_frame(frame) class TestFrameDecoder: def _single_frame_test( self, client: bool, frame_bytes: bytes, opcode: fp.Opcode, payload: bytes, frame_finished: bool, message_finished: bool, ) -> None: decoder = fp.FrameDecoder(client=client) decoder.receive_bytes(frame_bytes) frame = decoder.process_buffer() assert frame is not None assert frame.opcode is opcode assert frame.payload == payload assert frame.frame_finished is frame_finished assert frame.message_finished is message_finished def _split_frame_test( self, client: bool, frame_bytes: bytes, opcode: fp.Opcode, payload: bytes, frame_finished: bool, message_finished: bool, split: int, ) -> None: decoder = fp.FrameDecoder(client=client) decoder.receive_bytes(frame_bytes[:split]) assert decoder.process_buffer() is None decoder.receive_bytes(frame_bytes[split:]) frame = decoder.process_buffer() assert frame is not None assert frame.opcode is opcode assert frame.payload == payload assert frame.frame_finished is frame_finished assert frame.message_finished is message_finished def _split_message_test( self, client: bool, frame_bytes: bytes, opcode: fp.Opcode, payload: bytes, split: int, ) -> None: decoder = fp.FrameDecoder(client=client) decoder.receive_bytes(frame_bytes[:split]) frame = decoder.process_buffer() assert frame is not None assert frame.opcode is opcode assert frame.payload == payload[: len(frame.payload)] assert frame.frame_finished is False assert frame.message_finished is True decoder.receive_bytes(frame_bytes[split:]) frame = decoder.process_buffer() assert frame is not None assert frame.opcode is fp.Opcode.CONTINUATION assert frame.payload == payload[-len(frame.payload) :] assert frame.frame_finished is True assert frame.message_finished is True def _parse_failure_test( self, client: bool, frame_bytes: bytes, close_reason: fp.CloseReason ) -> None: decoder = fp.FrameDecoder(client=client) with pytest.raises(fp.ParseFailed) as excinfo: decoder.receive_bytes(frame_bytes) decoder.process_buffer() assert excinfo.value.code is close_reason def test_zero_length_message(self) -> None: self._single_frame_test( client=True, frame_bytes=b"\x81\x00", opcode=fp.Opcode.TEXT, payload=b"", frame_finished=True, message_finished=True, ) def test_short_server_message_frame(self) -> None: self._single_frame_test( client=True, frame_bytes=b"\x81\x02xy", opcode=fp.Opcode.TEXT, payload=b"xy", frame_finished=True, message_finished=True, ) def test_short_client_message_frame(self) -> None: self._single_frame_test( client=False, frame_bytes=b"\x81\x82abcd\x19\x1b", opcode=fp.Opcode.TEXT, payload=b"xy", frame_finished=True, message_finished=True, ) def test_reject_masked_server_frame(self) -> None: self._parse_failure_test( client=True, frame_bytes=b"\x81\x82abcd\x19\x1b", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_reject_unmasked_client_frame(self) -> None: self._parse_failure_test( client=False, frame_bytes=b"\x81\x02xy", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_reject_bad_opcode(self) -> None: self._parse_failure_test( client=True, frame_bytes=b"\x8e\x02xy", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_reject_unfinished_control_frame(self) -> None: self._parse_failure_test( client=True, frame_bytes=b"\x09\x02xy", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_reject_reserved_bits(self) -> None: self._parse_failure_test( client=True, frame_bytes=b"\x91\x02xy", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) self._parse_failure_test( client=True, frame_bytes=b"\xa1\x02xy", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) self._parse_failure_test( client=True, frame_bytes=b"\xc1\x02xy", close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_long_message_frame(self) -> None: payload = b"x" * 512 payload_len = struct.pack("!H", len(payload)) frame_bytes = b"\x81\x7e" + payload_len + payload self._single_frame_test( client=True, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, ) def test_very_long_message_frame(self) -> None: payload = b"x" * (128 * 1024) payload_len = struct.pack("!Q", len(payload)) frame_bytes = b"\x81\x7f" + payload_len + payload self._single_frame_test( client=True, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, ) def test_insufficiently_long_message_frame(self) -> None: payload = b"x" * 64 payload_len = struct.pack("!H", len(payload)) frame_bytes = b"\x81\x7e" + payload_len + payload self._parse_failure_test( client=True, frame_bytes=frame_bytes, close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_insufficiently_very_long_message_frame(self) -> None: payload = b"x" * 512 payload_len = struct.pack("!Q", len(payload)) frame_bytes = b"\x81\x7f" + payload_len + payload self._parse_failure_test( client=True, frame_bytes=frame_bytes, close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_very_insufficiently_very_long_message_frame(self) -> None: payload = b"x" * 64 payload_len = struct.pack("!Q", len(payload)) frame_bytes = b"\x81\x7f" + payload_len + payload self._parse_failure_test( client=True, frame_bytes=frame_bytes, close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_not_enough_for_header(self) -> None: payload = b"xy" frame_bytes = b"\x81\x02" + payload self._split_frame_test( client=True, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, split=1, ) def test_not_enough_for_long_length(self) -> None: payload = b"x" * 512 payload_len = struct.pack("!H", len(payload)) frame_bytes = b"\x81\x7e" + payload_len + payload self._split_frame_test( client=True, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, split=3, ) def test_not_enough_for_very_long_length(self) -> None: payload = b"x" * (128 * 1024) payload_len = struct.pack("!Q", len(payload)) frame_bytes = b"\x81\x7f" + payload_len + payload self._split_frame_test( client=True, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, split=7, ) def test_eight_byte_length_with_msb_set(self) -> None: frame_bytes = b"\x81\x7f\x80\x80\x80\x80\x80\x80\x80\x80" self._parse_failure_test( client=True, frame_bytes=frame_bytes, close_reason=fp.CloseReason.PROTOCOL_ERROR, ) def test_not_enough_for_mask(self) -> None: payload = bytearray(b"xy") mask = bytearray(b"abcd") masked_payload = bytearray([payload[0] ^ mask[0], payload[1] ^ mask[1]]) frame_bytes = b"\x81\x82" + mask + masked_payload self._split_frame_test( client=False, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, frame_finished=True, message_finished=True, split=4, ) def test_partial_message_frames(self) -> None: chunk_size = 1024 payload = b"x" * (128 * chunk_size) payload_len = struct.pack("!Q", len(payload)) frame_bytes = b"\x81\x7f" + payload_len + payload header_len = len(frame_bytes) - len(payload) decoder = fp.FrameDecoder(client=True) decoder.receive_bytes(frame_bytes[:header_len]) assert decoder.process_buffer() is None frame_bytes = frame_bytes[header_len:] payload_sent = 0 expected_opcode = fp.Opcode.TEXT for offset in range(0, len(frame_bytes), chunk_size): chunk = frame_bytes[offset : offset + chunk_size] decoder.receive_bytes(chunk) frame = decoder.process_buffer() payload_sent += chunk_size all_payload_sent = payload_sent == len(payload) assert frame is not None assert frame.opcode is expected_opcode assert frame.frame_finished is all_payload_sent assert frame.message_finished is True assert frame.payload == payload[offset : offset + chunk_size] expected_opcode = fp.Opcode.CONTINUATION def test_partial_control_frame(self) -> None: chunk_size = 11 payload = b"x" * 64 frame_bytes = b"\x89" + bytearray([len(payload)]) + payload decoder = fp.FrameDecoder(client=True) for offset in range(0, len(frame_bytes) - chunk_size, chunk_size): chunk = frame_bytes[offset : offset + chunk_size] decoder.receive_bytes(chunk) assert decoder.process_buffer() is None decoder.receive_bytes(frame_bytes[-chunk_size:]) frame = decoder.process_buffer() assert frame is not None assert frame.opcode is fp.Opcode.PING assert frame.frame_finished is True assert frame.message_finished is True assert frame.payload == payload def test_long_message_sliced(self) -> None: payload = b"x" * 65535 payload_len = struct.pack("!H", len(payload)) frame_bytes = b"\x81\x7e" + payload_len + payload self._split_message_test( client=True, frame_bytes=frame_bytes, opcode=fp.Opcode.TEXT, payload=payload, split=65535, ) def test_overly_long_control_frame(self) -> None: payload = b"x" * 128 payload_len = struct.pack("!H", len(payload)) frame_bytes = b"\x89\x7e" + payload_len + payload self._parse_failure_test( client=True, frame_bytes=frame_bytes, close_reason=fp.CloseReason.PROTOCOL_ERROR, ) class TestFrameDecoderExtensions: class FakeExtension(wpext.Extension): name = "fake" def __init__(self) -> None: self._inbound_header_called = False self._inbound_rsv_bit_set = False self._inbound_payload_data_called = False self._inbound_complete_called = False self._fail_inbound_complete = False self._outbound_rsv_bit_set = False def enabled(self) -> bool: return True def frame_inbound_header( self, proto: Union[fp.FrameDecoder, fp.FrameProtocol], opcode: fp.Opcode, rsv: fp.RsvBits, payload_length: int, ) -> Union[fp.CloseReason, fp.RsvBits]: self._inbound_header_called = True if opcode is fp.Opcode.PONG: return fp.CloseReason.MANDATORY_EXT self._inbound_rsv_bit_set = rsv.rsv3 return fp.RsvBits(False, False, True) def frame_inbound_payload_data( self, proto: Union[fp.FrameDecoder, fp.FrameProtocol], data: bytes ) -> Union[bytes, fp.CloseReason]: self._inbound_payload_data_called = True if data == b"party time": return fp.CloseReason.POLICY_VIOLATION elif data == b"ragequit": self._fail_inbound_complete = True if self._inbound_rsv_bit_set: data = data.decode("utf-8").upper().encode("utf-8") return data def frame_inbound_complete( self, proto: Union[fp.FrameDecoder, fp.FrameProtocol], fin: bool ) -> Union[bytes, fp.CloseReason, None]: self._inbound_complete_called = True if self._fail_inbound_complete: return fp.CloseReason.ABNORMAL_CLOSURE if fin and self._inbound_rsv_bit_set: return "™".encode() return None def frame_outbound( self, proto: Union[fp.FrameDecoder, fp.FrameProtocol], opcode: fp.Opcode, rsv: fp.RsvBits, data: bytes, fin: bool, ) -> Tuple[fp.RsvBits, bytes]: if opcode is fp.Opcode.TEXT: rsv = fp.RsvBits(rsv.rsv1, rsv.rsv2, True) self._outbound_rsv_bit_set = True if fin and self._outbound_rsv_bit_set: data += "®".encode() self._outbound_rsv_bit_set = False return rsv, data def test_rsv_bit(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) frame_bytes = b"\x91\x00" decoder.receive_bytes(frame_bytes) frame = decoder.process_buffer() assert frame is not None assert ext._inbound_header_called assert ext._inbound_rsv_bit_set def test_wrong_rsv_bit(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) frame_bytes = b"\xa1\x00" decoder.receive_bytes(frame_bytes) with pytest.raises(fp.ParseFailed) as excinfo: decoder.receive_bytes(frame_bytes) decoder.process_buffer() assert excinfo.value.code is fp.CloseReason.PROTOCOL_ERROR def test_header_error_handling(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) frame_bytes = b"\x9a\x00" decoder.receive_bytes(frame_bytes) with pytest.raises(fp.ParseFailed) as excinfo: decoder.receive_bytes(frame_bytes) decoder.process_buffer() assert excinfo.value.code is fp.CloseReason.MANDATORY_EXT def test_payload_processing(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) payload = "fñör∂" expected_payload = payload.upper().encode("utf-8") bytes_payload = payload.encode("utf-8") frame_bytes = b"\x11" + bytearray([len(bytes_payload)]) + bytes_payload decoder.receive_bytes(frame_bytes) frame = decoder.process_buffer() assert frame is not None assert ext._inbound_header_called assert ext._inbound_rsv_bit_set assert ext._inbound_payload_data_called assert frame.payload == expected_payload def test_no_payload_processing_when_not_wanted(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) payload = "fñör∂" expected_payload = payload.encode("utf-8") bytes_payload = payload.encode("utf-8") frame_bytes = b"\x01" + bytearray([len(bytes_payload)]) + bytes_payload decoder.receive_bytes(frame_bytes) frame = decoder.process_buffer() assert frame is not None assert ext._inbound_header_called assert not ext._inbound_rsv_bit_set assert ext._inbound_payload_data_called assert frame.payload == expected_payload def test_payload_error_handling(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) payload = b"party time" frame_bytes = b"\x91" + bytearray([len(payload)]) + payload decoder.receive_bytes(frame_bytes) with pytest.raises(fp.ParseFailed) as excinfo: decoder.receive_bytes(frame_bytes) decoder.process_buffer() assert excinfo.value.code is fp.CloseReason.POLICY_VIOLATION def test_frame_completion(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) payload = "fñör∂" expected_payload = (payload + "™").upper().encode("utf-8") bytes_payload = payload.encode("utf-8") frame_bytes = b"\x91" + bytearray([len(bytes_payload)]) + bytes_payload decoder.receive_bytes(frame_bytes) frame = decoder.process_buffer() assert frame is not None assert ext._inbound_header_called assert ext._inbound_rsv_bit_set assert ext._inbound_payload_data_called assert ext._inbound_complete_called assert frame.payload == expected_payload def test_no_frame_completion_when_not_wanted(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) payload = "fñör∂" expected_payload = payload.encode("utf-8") bytes_payload = payload.encode("utf-8") frame_bytes = b"\x81" + bytearray([len(bytes_payload)]) + bytes_payload decoder.receive_bytes(frame_bytes) frame = decoder.process_buffer() assert frame is not None assert ext._inbound_header_called assert not ext._inbound_rsv_bit_set assert ext._inbound_payload_data_called assert ext._inbound_complete_called assert frame.payload == expected_payload def test_completion_error_handling(self) -> None: ext = self.FakeExtension() decoder = fp.FrameDecoder(client=True, extensions=[ext]) payload = b"ragequit" frame_bytes = b"\x91" + bytearray([len(payload)]) + payload decoder.receive_bytes(frame_bytes) with pytest.raises(fp.ParseFailed) as excinfo: decoder.receive_bytes(frame_bytes) decoder.process_buffer() assert excinfo.value.code is fp.CloseReason.ABNORMAL_CLOSURE def test_outbound_handling_single_frame(self) -> None: ext = self.FakeExtension() proto = fp.FrameProtocol(client=False, extensions=[ext]) payload = "😃😄🙃😉" data = proto.send_data(payload, fin=True) payload_bytes = (payload + "®").encode("utf8") assert data == b"\x91" + bytearray([len(payload_bytes)]) + payload_bytes def test_outbound_handling_multiple_frames(self) -> None: ext = self.FakeExtension() proto = fp.FrameProtocol(client=False, extensions=[ext]) payload = "😃😄🙃😉" data = proto.send_data(payload, fin=False) payload_bytes = payload.encode("utf8") assert data == b"\x11" + bytearray([len(payload_bytes)]) + payload_bytes payload = r"¯\_(ツ)_/¯" data = proto.send_data(payload, fin=True) payload_bytes = (payload + "®").encode("utf8") assert data == b"\x80" + bytearray([len(payload_bytes)]) + payload_bytes class TestFrameProtocolReceive: def test_long_text_message(self) -> None: payload = "x" * 65535 encoded_payload = payload.encode("utf-8") payload_len = struct.pack("!H", len(encoded_payload)) frame_bytes = b"\x81\x7e" + payload_len + encoded_payload protocol = fp.FrameProtocol(client=True, extensions=[]) protocol.receive_bytes(frame_bytes) frames = list(protocol.received_frames()) assert len(frames) == 1 frame = frames[0] assert frame.opcode == fp.Opcode.TEXT assert len(frame.payload) == len(payload) assert frame.payload == payload def _close_test( self, code: Optional[int], reason: Optional[str] = None, reason_bytes: Optional[bytes] = None, ) -> None: payload = b"" if code: payload += struct.pack("!H", code) if reason: payload += reason.encode("utf8") elif reason_bytes: payload += reason_bytes frame_bytes = b"\x88" + bytearray([len(payload)]) + payload protocol = fp.FrameProtocol(client=True, extensions=[]) protocol.receive_bytes(frame_bytes) frames = list(protocol.received_frames()) assert len(frames) == 1 frame = frames[0] assert frame.opcode == fp.Opcode.CLOSE assert frame.payload[0] == code or fp.CloseReason.NO_STATUS_RCVD if reason: assert frame.payload[1] == reason else: assert not frame.payload[1] def test_close_no_code(self) -> None: self._close_test(None) def test_close_one_byte_code(self) -> None: frame_bytes = b"\x88\x01\x0e" protocol = fp.FrameProtocol(client=True, extensions=[]) with pytest.raises(fp.ParseFailed) as exc: protocol.receive_bytes(frame_bytes) list(protocol.received_frames()) assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR def test_close_bad_code(self) -> None: with pytest.raises(fp.ParseFailed) as exc: self._close_test(123) assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR def test_close_unknown_code(self) -> None: with pytest.raises(fp.ParseFailed) as exc: self._close_test(2998) assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR def test_close_local_only_code(self) -> None: with pytest.raises(fp.ParseFailed) as exc: self._close_test(fp.CloseReason.NO_STATUS_RCVD) assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR def test_close_no_payload(self) -> None: self._close_test(fp.CloseReason.NORMAL_CLOSURE) def test_close_easy_payload(self) -> None: self._close_test(fp.CloseReason.NORMAL_CLOSURE, "tarah old chap") def test_close_utf8_payload(self) -> None: self._close_test(fp.CloseReason.NORMAL_CLOSURE, "fñør∂") def test_close_bad_utf8_payload(self) -> None: payload = unhexlify("cebae1bdb9cf83cebcceb5eda080656469746564") with pytest.raises(fp.ParseFailed) as exc: self._close_test(fp.CloseReason.NORMAL_CLOSURE, reason_bytes=payload) assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA def test_close_incomplete_utf8_payload(self) -> None: payload = "fñør∂".encode()[:-1] with pytest.raises(fp.ParseFailed) as exc: self._close_test(fp.CloseReason.NORMAL_CLOSURE, reason_bytes=payload) assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA def test_random_control_frame(self) -> None: payload = b"give me one ping vasily" frame_bytes = b"\x89" + bytearray([len(payload)]) + payload protocol = fp.FrameProtocol(client=True, extensions=[]) protocol.receive_bytes(frame_bytes) frames = list(protocol.received_frames()) assert len(frames) == 1 frame = frames[0] assert frame.opcode == fp.Opcode.PING assert len(frame.payload) == len(payload) assert frame.payload == payload class TestFrameProtocolSend: def test_simplest_possible_close(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) data = proto.close() assert data == b"\x88\x00" def test_unreasoning_close(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE) assert data == b"\x88\x02\x03\xe8" def test_reasoned_close(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) reason = r"¯\_(ツ)_/¯" expected_payload = struct.pack( "!H", fp.CloseReason.NORMAL_CLOSURE ) + reason.encode("utf8") data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE, reason=reason) assert data == b"\x88" + bytearray([len(expected_payload)]) + expected_payload def test_overly_reasoned_close(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) reason = r"¯\_(ツ)_/¯" * 10 data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE, reason=reason) assert bytes(data[0:1]) == b"\x88" assert len(data) <= 127 assert data[4:].decode("utf8") def test_reasoned_but_uncoded_close(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) with pytest.raises(TypeError): proto.close(reason="termites") def test_no_status_rcvd_close_reason(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) data = proto.close(code=fp.CloseReason.NO_STATUS_RCVD) assert data == b"\x88\x00" def test_local_only_close_reason(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) data = proto.close(code=fp.CloseReason.ABNORMAL_CLOSURE) assert data == b"\x88\x02\x03\xe8" def test_ping_without_payload(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) data = proto.ping() assert data == b"\x89\x00" def test_ping_with_payload(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = r"¯\_(ツ)_/¯".encode() data = proto.ping(payload) assert data == b"\x89" + bytearray([len(payload)]) + payload def test_pong_without_payload(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) data = proto.pong() assert data == b"\x8a\x00" def test_pong_with_payload(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = r"¯\_(ツ)_/¯".encode() data = proto.pong(payload) assert data == b"\x8a" + bytearray([len(payload)]) + payload def test_single_short_binary_data(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"it's all just ascii, right?" data = proto.send_data(payload, fin=True) assert data == b"\x82" + bytearray([len(payload)]) + payload def test_single_short_text_data(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = "😃😄🙃😉" data = proto.send_data(payload, fin=True) payload_bytes = payload.encode("utf8") assert data == b"\x81" + bytearray([len(payload_bytes)]) + payload_bytes def test_multiple_short_binary_data(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"it's all just ascii, right?" data = proto.send_data(payload, fin=False) assert data == b"\x02" + bytearray([len(payload)]) + payload payload = b"sure no worries" data = proto.send_data(payload, fin=True) assert data == b"\x80" + bytearray([len(payload)]) + payload def test_multiple_short_text_data(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = "😃😄🙃😉" data = proto.send_data(payload, fin=False) payload_bytes = payload.encode("utf8") assert data == b"\x01" + bytearray([len(payload_bytes)]) + payload_bytes payload = "🙈🙉🙊" data = proto.send_data(payload, fin=True) payload_bytes = payload.encode("utf8") assert data == b"\x80" + bytearray([len(payload_bytes)]) + payload_bytes def test_mismatched_data_messages1(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = "😃😄🙃😉" data = proto.send_data(payload, fin=False) payload_bytes = payload.encode("utf8") assert data == b"\x01" + bytearray([len(payload_bytes)]) + payload_bytes payload_bytes = b"seriously, all ascii" with pytest.raises(TypeError): proto.send_data(payload_bytes) def test_mismatched_data_messages2(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"it's all just ascii, right?" data = proto.send_data(payload, fin=False) assert data == b"\x02" + bytearray([len(payload)]) + payload payload_str = "✔️☑️✅✔︎☑" with pytest.raises(TypeError): proto.send_data(payload_str) def test_message_length_max_short(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"x" * 125 data = proto.send_data(payload, fin=True) assert data == b"\x82" + bytearray([len(payload)]) + payload def test_message_length_min_two_byte(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"x" * 126 data = proto.send_data(payload, fin=True) assert data == b"\x82\x7e" + struct.pack("!H", len(payload)) + payload def test_message_length_max_two_byte(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"x" * (2**16 - 1) data = proto.send_data(payload, fin=True) assert data == b"\x82\x7e" + struct.pack("!H", len(payload)) + payload def test_message_length_min_eight_byte(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"x" * (2**16) data = proto.send_data(payload, fin=True) assert data == b"\x82\x7f" + struct.pack("!Q", len(payload)) + payload def test_client_side_masking_short_frame(self) -> None: proto = fp.FrameProtocol(client=True, extensions=[]) payload = b"x" * 125 data = proto.send_data(payload, fin=True) assert data[0] == 0x82 assert struct.unpack("!B", data[1:2])[0] == len(payload) | 0x80 masking_key = data[2:6] maskbytes = itertools.cycle(masking_key) assert data[6:] == bytearray(b ^ next(maskbytes) for b in bytearray(payload)) def test_client_side_masking_two_byte_frame(self) -> None: proto = fp.FrameProtocol(client=True, extensions=[]) payload = b"x" * 126 data = proto.send_data(payload, fin=True) assert data[0] == 0x82 assert data[1] == 0xFE assert struct.unpack("!H", data[2:4])[0] == len(payload) masking_key = data[4:8] maskbytes = itertools.cycle(masking_key) assert data[8:] == bytearray(b ^ next(maskbytes) for b in bytearray(payload)) def test_client_side_masking_eight_byte_frame(self) -> None: proto = fp.FrameProtocol(client=True, extensions=[]) payload = b"x" * 65536 data = proto.send_data(payload, fin=True) assert data[0] == 0x82 assert data[1] == 0xFF assert struct.unpack("!Q", data[2:10])[0] == len(payload) masking_key = data[10:14] maskbytes = itertools.cycle(masking_key) assert data[14:] == bytearray(b ^ next(maskbytes) for b in bytearray(payload)) def test_control_frame_with_overly_long_payload(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload = b"x" * 126 with pytest.raises(ValueError): proto.pong(payload) def test_data_we_have_no_idea_what_to_do_with(self) -> None: proto = fp.FrameProtocol(client=False, extensions=[]) payload: Dict[str, str] = dict() with pytest.raises(ValueError): # Intentionally passing illegal type. proto.send_data(payload) # type: ignore def test_xor_mask_simple() -> None: masker = fp.XorMaskerSimple(b"1234") assert masker.process(b"") == b"" assert masker.process(b"some very long data for masking by websocket") == ( b"B]^Q\x11DVFH\x12_[_U\x13PPFR\x14W]A\x14\\S@_X\\T\x14SK\x13CTP@[RYV@" ) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_handshake.py0000664000175000017500000000725000000000000020442 0ustar00ubuntuubuntu00000000000000import pytest from wsproto.connection import CLIENT, ConnectionState, SERVER from wsproto.events import AcceptConnection, Ping, Request from wsproto.handshake import H11Handshake from wsproto.utilities import LocalProtocolError, RemoteProtocolError def test_successful_handshake() -> None: client = H11Handshake(CLIENT) server = H11Handshake(SERVER) server.receive_data(client.send(Request(host="localhost", target="/"))) assert isinstance(next(server.events()), Request) client.receive_data(server.send(AcceptConnection())) assert isinstance(next(client.events()), AcceptConnection) assert client.state is ConnectionState.OPEN assert server.state is ConnectionState.OPEN assert repr(client) == "H11Handshake(client=True, state=ConnectionState.OPEN)" assert repr(server) == "H11Handshake(client=False, state=ConnectionState.OPEN)" def test_host_encoding() -> None: client = H11Handshake(CLIENT) server = H11Handshake(SERVER) data = client.send(Request(host="芝士汉堡", target="/")) assert b"Host: xn--7ks3rz39bh7u" in data server.receive_data(data) request = next(server.events()) assert isinstance(request, Request) assert request.host == "芝士汉堡" @pytest.mark.parametrize("http", [b"HTTP/1.0", b"HTTP/1.1"]) def test_rejected_handshake(http: bytes) -> None: server = H11Handshake(SERVER) with pytest.raises(RemoteProtocolError): server.receive_data( b"GET / " + http + b"\r\n" b"Upgrade: WebSocket\r\n" b"Connection: Upgrade\r\n" b"Sec-WebSocket-Key: VQr8cvwwZ1fEk62PDq8J3A==\r\n" b"Sec-WebSocket-Version: 13\r\n" b"\r\n" ) def test_initiate_upgrade_as_client() -> None: client = H11Handshake(CLIENT) with pytest.raises(LocalProtocolError): client.initiate_upgrade_connection([], "/") def test_send_invalid_event() -> None: client = H11Handshake(CLIENT) with pytest.raises(LocalProtocolError): client.send(Ping()) def test_h11_multiple_headers_handshake() -> None: server = H11Handshake(SERVER) data = ( b"GET wss://api.website.xyz/ws HTTP/1.1\r\n" b"Host: api.website.xyz\r\n" b"Connection: Upgrade\r\n" b"Pragma: no-cache\r\n" b"Cache-Control: no-cache\r\n" b"User-Agent: Mozilla/5.0 (X11; Linux x86_64) " b"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.114 Safari/537.36\r\n" b"Upgrade: websocket\r\n" b"Origin: https://website.xyz\r\n" b"Sec-WebSocket-Version: 13\r\n" b"Accept-Encoding: gzip, deflate, br\r\n" b"Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7\r\n" b"Sec-WebSocket-Key: tOzeAzi9xK7ADxxEdTzmaA==\r\n" b"Sec-WebSocket-Extensions: this-extension; isnt-seen, even-tho, it-should-be\r\n" b"Sec-WebSocket-Protocol: there-protocols\r\n" b"Sec-WebSocket-Protocol: arent-seen\r\n" b"Sec-WebSocket-Extensions: this-extension; were-gonna-see, and-another-extension; were-also; gonna-see=100; percent\r\n" # noqa: E501 b"Sec-WebSocket-Protocol: only-these-protocols, are-seen, from-the-request-object\r\n" b"\r\n" ) server.receive_data(data) request = next(server.events()) assert isinstance(request, Request) assert request.subprotocols == [ "there-protocols", "arent-seen", "only-these-protocols", "are-seen", "from-the-request-object", ] assert request.extensions == [ "this-extension; isnt-seen", "even-tho", "it-should-be", "this-extension; were-gonna-see", "and-another-extension; were-also; gonna-see=100; percent", ] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_permessage_deflate.py0000664000175000017500000004326500000000000022341 0ustar00ubuntuubuntu00000000000000import zlib from typing import cast, Optional, Sequence, TYPE_CHECKING import pytest from wsproto import extensions as wpext, frame_protocol as fp if TYPE_CHECKING: from mypy_extensions import TypedDict class Params(TypedDict, total=False): client_no_context_takeover: bool client_max_window_bits: Optional[int] server_no_context_takeover: bool server_max_window_bits: Optional[int] else: Params = dict class TestPerMessageDeflate: parameter_sets: Sequence[Params] = [ { "client_no_context_takeover": False, "client_max_window_bits": 15, "server_no_context_takeover": False, "server_max_window_bits": 15, }, { "client_no_context_takeover": True, "client_max_window_bits": 9, "server_no_context_takeover": False, "server_max_window_bits": 15, }, { "client_no_context_takeover": False, "client_max_window_bits": 15, "server_no_context_takeover": True, "server_max_window_bits": 9, }, { "client_no_context_takeover": True, "client_max_window_bits": 9, "server_no_context_takeover": True, "server_max_window_bits": 9, }, {"client_no_context_takeover": True, "server_max_window_bits": 9}, {"server_no_context_takeover": True, "client_max_window_bits": 9}, {"client_max_window_bits": None, "server_max_window_bits": None}, {}, ] def make_offer_string(self, params: Params) -> str: offer = ["permessage-deflate"] if "client_max_window_bits" in params: if params["client_max_window_bits"] is None: offer.append("client_max_window_bits") else: offer.append( "client_max_window_bits=%d" % params["client_max_window_bits"] ) if "server_max_window_bits" in params: if params["server_max_window_bits"] is None: offer.append("server_max_window_bits") else: offer.append( "server_max_window_bits=%d" % params["server_max_window_bits"] ) if params.get("client_no_context_takeover", False): offer.append("client_no_context_takeover") if params.get("server_no_context_takeover", False): offer.append("server_no_context_takeover") return "; ".join(offer) def compare_params_to_string( self, params: Params, ext: wpext.PerMessageDeflate, param_string: str ) -> None: if "client_max_window_bits" in params: if params["client_max_window_bits"] is None: bits = ext.client_max_window_bits else: bits = params["client_max_window_bits"] assert "client_max_window_bits=%d" % bits in param_string if "server_max_window_bits" in params: if params["server_max_window_bits"] is None: bits = ext.server_max_window_bits else: bits = params["server_max_window_bits"] assert "server_max_window_bits=%d" % bits in param_string if params.get("client_no_context_takeover", False): assert "client_no_context_takeover" in param_string if params.get("server_no_context_takeover", False): assert "server_no_context_takeover" in param_string @pytest.mark.parametrize("params", parameter_sets) def test_offer(self, params: Params) -> None: ext = wpext.PerMessageDeflate(**params) offer = ext.offer() offer = cast(str, offer) self.compare_params_to_string(params, ext, offer) @pytest.mark.parametrize("params", parameter_sets) def test_finalize(self, params: Params) -> None: ext = wpext.PerMessageDeflate() assert not ext.enabled() if "client_max_window_bits" in params: if params["client_max_window_bits"] is None: del params["client_max_window_bits"] if "server_max_window_bits" in params: if params["server_max_window_bits"] is None: del params["server_max_window_bits"] offer = self.make_offer_string(params) ext.finalize(offer) if params.get("client_max_window_bits", None): assert ext.client_max_window_bits == params["client_max_window_bits"] if params.get("server_max_window_bits", None): assert ext.server_max_window_bits == params["server_max_window_bits"] assert ext.client_no_context_takeover is params.get( "client_no_context_takeover", False ) assert ext.server_no_context_takeover is params.get( "server_no_context_takeover", False ) assert ext.enabled() def test_finalize_ignores_rubbish(self) -> None: ext = wpext.PerMessageDeflate() assert not ext.enabled() ext.finalize("i am the lizard queen; worship me") assert ext.enabled() @pytest.mark.parametrize("params", parameter_sets) def test_accept(self, params: Params) -> None: ext = wpext.PerMessageDeflate() assert not ext.enabled() offer = self.make_offer_string(params) response = ext.accept(offer) response = cast(str, response) if ext.client_no_context_takeover: assert "client_no_context_takeover" in response if ext.server_no_context_takeover: assert "server_no_context_takeover" in response if "client_max_window_bits" in params: if params["client_max_window_bits"] is None: bits = ext.client_max_window_bits else: bits = params["client_max_window_bits"] assert ext.client_max_window_bits == bits assert "client_max_window_bits=%d" % bits in response if "server_max_window_bits" in params: if params["server_max_window_bits"] is None: bits = ext.server_max_window_bits else: bits = params["server_max_window_bits"] assert ext.server_max_window_bits == bits assert "server_max_window_bits=%d" % bits in response def test_accept_ignores_rubbish(self) -> None: ext = wpext.PerMessageDeflate() assert not ext.enabled() ext.accept("i am the lizard queen; worship me") assert ext.enabled() def test_inbound_uncompressed_control_frame(self) -> None: payload = b"x" * 23 ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.PING, fp.RsvBits(False, False, False), len(payload) ) assert isinstance(result, fp.RsvBits) assert result.rsv1 data = ext.frame_inbound_payload_data(proto, payload) assert data == payload assert ext.frame_inbound_complete(proto, True) is None def test_inbound_compressed_control_frame(self) -> None: payload = b"x" * 23 ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.PING, fp.RsvBits(True, False, False), len(payload) ) assert result == fp.CloseReason.PROTOCOL_ERROR def test_inbound_compressed_continuation_frame(self) -> None: payload = b"x" * 23 ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.CONTINUATION, fp.RsvBits(True, False, False), len(payload) ) assert result == fp.CloseReason.PROTOCOL_ERROR def test_inbound_uncompressed_data_frame(self) -> None: payload = b"x" * 23 ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(False, False, False), len(payload) ) assert isinstance(result, fp.RsvBits) assert result.rsv1 data = ext.frame_inbound_payload_data(proto, payload) assert data == payload assert ext.frame_inbound_complete(proto, True) is None @pytest.mark.parametrize("client", [True, False]) def test_client_inbound_compressed_single_data_frame(self, client: bool) -> None: payload = b"x" * 23 compressed_payload = b"\xaa\xa8\xc0\n\x00\x00" ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(True, False, False), len(compressed_payload), ) assert isinstance(result, fp.RsvBits) assert result.rsv1 data = ext.frame_inbound_payload_data(proto, compressed_payload) assert isinstance(data, bytes) data2 = ext.frame_inbound_complete(proto, True) assert isinstance(data2, bytes) assert data + data2 == payload @pytest.mark.parametrize("client", [True, False]) def test_client_inbound_compressed_multiple_data_frames(self, client: bool) -> None: payload = b"x" * 23 compressed_payload = b"\xaa\xa8\xc0\n\x00\x00" split = 3 data = b"" ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(True, False, False), split ) assert isinstance(result, fp.RsvBits) assert result.rsv1 result2 = ext.frame_inbound_payload_data(proto, compressed_payload[:split]) assert not isinstance(result2, fp.CloseReason) data += result2 assert ext.frame_inbound_complete(proto, False) is None result3 = ext.frame_inbound_header( proto, fp.Opcode.CONTINUATION, fp.RsvBits(False, False, False), len(compressed_payload) - split, ) assert isinstance(result3, fp.RsvBits) assert result3.rsv1 result4 = ext.frame_inbound_payload_data(proto, compressed_payload[split:]) assert not isinstance(result4, fp.CloseReason) data += result4 result5 = ext.frame_inbound_complete(proto, True) assert isinstance(result5, bytes) data += result5 assert data == payload @pytest.mark.parametrize("client", [True, False]) def test_client_decompress_after_uncompressible_frame(self, client: bool) -> None: ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) # A PING frame ext.frame_inbound_header( proto, fp.Opcode.PING, fp.RsvBits(False, False, False), 0 ) result2 = ext.frame_inbound_payload_data(proto, b"") assert not isinstance(result2, fp.CloseReason) assert ext.frame_inbound_complete(proto, True) is None # A compressed TEXT frame payload = b"x" * 23 compressed_payload = b"\xaa\xa8\xc0\n\x00\x00" result3 = ext.frame_inbound_header( proto, fp.Opcode.TEXT, fp.RsvBits(True, False, False), len(compressed_payload), ) assert isinstance(result3, fp.RsvBits) assert result3.rsv1 result4 = ext.frame_inbound_payload_data(proto, compressed_payload) assert result4 == payload result5 = ext.frame_inbound_complete(proto, True) assert not isinstance(result5, fp.CloseReason) def test_inbound_bad_zlib_payload(self) -> None: compressed_payload = b"x" * 23 ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(True, False, False), len(compressed_payload), ) assert isinstance(result, fp.RsvBits) assert result.rsv1 result2 = ext.frame_inbound_payload_data(proto, compressed_payload) assert result2 is fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA def test_inbound_bad_zlib_decoder_end_state(self) -> None: compressed_payload = b"x" * 23 ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(True, False, False), len(compressed_payload), ) assert isinstance(result, fp.RsvBits) assert result.rsv1 class FailDecompressor: def decompress(self, data: bytes) -> bytes: return b"" def flush(self) -> None: raise zlib.error() ext._decompressor = cast("zlib._Decompress", FailDecompressor()) result2 = ext.frame_inbound_complete(proto, True) assert result2 is fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA @pytest.mark.parametrize( "client,no_context_takeover", [(True, True), (True, False), (False, True), (False, False)], ) def test_decompressor_reset(self, client: bool, no_context_takeover: bool) -> None: if client: args = {"server_no_context_takeover": no_context_takeover} else: args = {"client_no_context_takeover": no_context_takeover} ext = wpext.PerMessageDeflate(**args) ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) result = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(True, False, False), 0 ) assert isinstance(result, fp.RsvBits) assert result.rsv1 assert ext._decompressor is not None result2 = ext.frame_inbound_complete(proto, True) assert not isinstance(result2, fp.CloseReason) if no_context_takeover: assert ext._decompressor is None else: assert ext._decompressor is not None result3 = ext.frame_inbound_header( proto, fp.Opcode.BINARY, fp.RsvBits(True, False, False), 0 ) assert isinstance(result3, fp.RsvBits) assert result3.rsv1 assert ext._decompressor is not None def test_outbound_uncompressible_opcode(self) -> None: ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=True, extensions=[ext]) rsv = fp.RsvBits(False, False, False) payload = b"x" * 23 rsv, data = ext.frame_outbound(proto, fp.Opcode.PING, rsv, payload, True) assert rsv.rsv1 is False assert data == payload @pytest.mark.parametrize("client", [True, False]) def test_outbound_compress_single_frame(self, client: bool) -> None: ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) rsv = fp.RsvBits(False, False, False) payload = b"x" * 23 compressed_payload = b"\xaa\xa8\xc0\n\x00\x00" rsv, data = ext.frame_outbound(proto, fp.Opcode.BINARY, rsv, payload, True) assert rsv.rsv1 is True assert data == compressed_payload @pytest.mark.parametrize("client", [True, False]) def test_outbound_compress_multiple_frames(self, client: bool) -> None: ext = wpext.PerMessageDeflate() ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) rsv = fp.RsvBits(False, False, False) payload = b"x" * 23 split = 12 compressed_payload = b"\xaa\xa8\xc0\n\x00\x00" rsv, data = ext.frame_outbound( proto, fp.Opcode.BINARY, rsv, payload[:split], False ) assert rsv.rsv1 is True rsv = fp.RsvBits(False, False, False) rsv, more_data = ext.frame_outbound( proto, fp.Opcode.CONTINUATION, rsv, payload[split:], True ) assert rsv.rsv1 is False assert data + more_data == compressed_payload @pytest.mark.parametrize( "client,no_context_takeover", [(True, True), (True, False), (False, True), (False, False)], ) def test_compressor_reset(self, client: bool, no_context_takeover: bool) -> None: if client: args = {"client_no_context_takeover": no_context_takeover} else: args = {"server_no_context_takeover": no_context_takeover} ext = wpext.PerMessageDeflate(**args) ext._enabled = True proto = fp.FrameProtocol(client=client, extensions=[ext]) rsv = fp.RsvBits(False, False, False) rsv, data = ext.frame_outbound(proto, fp.Opcode.BINARY, rsv, b"", False) assert rsv.rsv1 is True assert ext._compressor is not None rsv = fp.RsvBits(False, False, False) rsv, data = ext.frame_outbound(proto, fp.Opcode.CONTINUATION, rsv, b"", True) assert rsv.rsv1 is False if no_context_takeover: assert ext._compressor is None else: assert ext._compressor is not None rsv = fp.RsvBits(False, False, False) rsv, data = ext.frame_outbound(proto, fp.Opcode.BINARY, rsv, b"", False) assert rsv.rsv1 is True assert ext._compressor is not None @pytest.mark.parametrize("params", parameter_sets) def test_repr(self, params: Params) -> None: ext = wpext.PerMessageDeflate(**params) self.compare_params_to_string(params, ext, repr(ext)) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/test/test_server.py0000664000175000017500000002611700000000000020025 0ustar00ubuntuubuntu00000000000000from typing import cast, List, Optional, Tuple import h11 import pytest from wsproto import WSConnection from wsproto.connection import SERVER from wsproto.events import AcceptConnection, RejectConnection, RejectData, Request from wsproto.extensions import Extension from wsproto.typing import Headers from wsproto.utilities import ( generate_accept_token, generate_nonce, normed_header_dict, RemoteProtocolError, ) from .helpers import FakeExtension def _make_connection_request(request_headers: Headers, method: str = "GET") -> Request: client = h11.Connection(h11.CLIENT) server = WSConnection(SERVER) server.receive_data( client.send(h11.Request(method=method, target="/", headers=request_headers)) ) event = next(server.events()) assert isinstance(event, Request) return event def test_connection_request() -> None: event = _make_connection_request( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", generate_nonce()), (b"X-Foo", b"bar"), ] ) assert event.extensions == [] assert event.host == "localhost" assert event.subprotocols == [] assert event.target == "/" headers = normed_header_dict(event.extra_headers) assert b"host" not in headers assert b"sec-websocket-extensions" not in headers assert b"sec-websocket-protocol" not in headers assert headers[b"connection"] == b"Keep-Alive, Upgrade" assert headers[b"sec-websocket-version"] == b"13" assert headers[b"upgrade"] == b"WebSocket" assert headers[b"x-foo"] == b"bar" def test_connection_request_bad_method() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_connection_request( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", generate_nonce()), ], method="POST", ) assert str(excinfo.value) == "Request method must be GET" def test_connection_request_bad_connection_header() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_connection_request( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, No-Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", generate_nonce()), ] ) assert str(excinfo.value) == "Missing header, 'Connection: Upgrade'" def test_connection_request_bad_upgrade_header() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_connection_request( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"h2c"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", generate_nonce()), ] ) assert str(excinfo.value) == "Missing header, 'Upgrade: WebSocket'" @pytest.mark.parametrize("version", [b"12", b"not-a-digit"]) def test_connection_request_bad_version_header(version: bytes) -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_connection_request( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", version), (b"Sec-WebSocket-Key", generate_nonce()), ] ) assert str(excinfo.value) == "Missing header, 'Sec-WebSocket-Version'" assert excinfo.value.event_hint == RejectConnection( headers=[(b"Sec-WebSocket-Version", b"13")], status_code=426 ) def test_connection_request_key_header() -> None: with pytest.raises(RemoteProtocolError) as excinfo: _make_connection_request( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), ] ) assert str(excinfo.value) == "Missing header, 'Sec-WebSocket-Key'" def test_upgrade_request() -> None: server = WSConnection(SERVER) server.initiate_upgrade_connection( [ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", generate_nonce()), (b"X-Foo", b"bar"), ], "/", ) event = next(server.events()) event = cast(Request, event) assert event.extensions == [] assert event.host == "localhost" assert event.subprotocols == [] assert event.target == "/" headers = normed_header_dict(event.extra_headers) assert b"host" not in headers assert b"sec-websocket-extensions" not in headers assert b"sec-websocket-protocol" not in headers assert headers[b"connection"] == b"Keep-Alive, Upgrade" assert headers[b"sec-websocket-version"] == b"13" assert headers[b"upgrade"] == b"WebSocket" assert headers[b"x-foo"] == b"bar" def _make_handshake( request_headers: Headers, accept_headers: Optional[Headers] = None, subprotocol: Optional[str] = None, extensions: Optional[List[Extension]] = None, ) -> Tuple[h11.InformationalResponse, bytes]: client = h11.Connection(h11.CLIENT) server = WSConnection(SERVER) nonce = generate_nonce() server.receive_data( client.send( h11.Request( method="GET", target="/", headers=[ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", nonce), ] + request_headers, ) ) ) client.receive_data( server.send( AcceptConnection( extra_headers=accept_headers or [], subprotocol=subprotocol, extensions=extensions or [], ) ) ) event = client.next_event() return cast(h11.InformationalResponse, event), nonce def test_handshake() -> None: response, nonce = _make_handshake([]) assert response.status_code == 101 assert sorted(response.headers) == [ (b"connection", b"Upgrade"), (b"sec-websocket-accept", generate_accept_token(nonce)), (b"upgrade", b"WebSocket"), ] def test_handshake_extra_headers() -> None: response, nonce = _make_handshake([], accept_headers=[(b"X-Foo", b"bar")]) assert response.status_code == 101 assert sorted(response.headers) == [ (b"connection", b"Upgrade"), (b"sec-websocket-accept", generate_accept_token(nonce)), (b"upgrade", b"WebSocket"), (b"x-foo", b"bar"), ] @pytest.mark.parametrize("accept_subprotocol", ["one", "two"]) def test_handshake_with_subprotocol(accept_subprotocol: str) -> None: response, _ = _make_handshake( [(b"Sec-Websocket-Protocol", b"one, two")], subprotocol=accept_subprotocol ) headers = normed_header_dict(response.headers) assert headers[b"sec-websocket-protocol"] == accept_subprotocol.encode("ascii") def test_handshake_with_extension() -> None: extension = FakeExtension(accept_response=True) response, _ = _make_handshake( [(b"Sec-Websocket-Extensions", extension.name.encode("ascii"))], extensions=[extension], ) headers = normed_header_dict(response.headers) assert headers[b"sec-websocket-extensions"] == extension.name.encode("ascii") def test_handshake_with_extension_params() -> None: offered_params = "parameter1=value3; parameter2=value4" accepted_params = "parameter1=value1; parameter2=value2" extension = FakeExtension(accept_response=accepted_params) response, _ = _make_handshake( [ ( b"Sec-Websocket-Extensions", (f"{extension.name}; {offered_params}").encode("ascii"), ) ], extensions=[extension], ) headers = normed_header_dict(response.headers) assert extension.offered == f"{extension.name}; {offered_params}" assert headers[b"sec-websocket-extensions"] == ( f"{extension.name}; {accepted_params}" ).encode("ascii") def test_handshake_with_extra_unaccepted_extension() -> None: extension = FakeExtension(accept_response=True) response, _ = _make_handshake( [ ( b"Sec-Websocket-Extensions", b"pretend, %s" % extension.name.encode("ascii"), ) ], extensions=[extension], ) headers = normed_header_dict(response.headers) assert headers[b"sec-websocket-extensions"] == extension.name.encode("ascii") def test_protocol_error() -> None: server = WSConnection(SERVER) with pytest.raises(RemoteProtocolError) as excinfo: server.receive_data(b"broken nonsense\r\n\r\n") assert str(excinfo.value) == "Bad HTTP message" def _make_handshake_rejection( status_code: int, body: Optional[bytes] = None ) -> List[h11.Event]: client = h11.Connection(h11.CLIENT) server = WSConnection(SERVER) nonce = generate_nonce() server.receive_data( client.send( h11.Request( method="GET", target="/", headers=[ (b"Host", b"localhost"), (b"Connection", b"Keep-Alive, Upgrade"), (b"Upgrade", b"WebSocket"), (b"Sec-WebSocket-Version", b"13"), (b"Sec-WebSocket-Key", nonce), ], ) ) ) if body is not None: client.receive_data( server.send( RejectConnection( headers=[(b"content-length", b"%d" % len(body))], status_code=status_code, has_body=True, ) ) ) client.receive_data(server.send(RejectData(data=body))) else: client.receive_data(server.send(RejectConnection(status_code=status_code))) events = [] while True: event = client.next_event() events.append(cast(h11.Event, event)) if isinstance(event, h11.EndOfMessage): return events def test_handshake_rejection() -> None: events = _make_handshake_rejection(400) assert events == [ h11.Response(headers=[(b"content-length", b"0")], status_code=400), h11.EndOfMessage(), ] def test_handshake_rejection_with_body() -> None: events = _make_handshake_rejection(400, body=b"Hello") assert events == [ h11.Response(headers=[(b"content-length", b"5")], status_code=400), h11.Data(data=b"Hello"), h11.EndOfMessage(), ] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1661284658.0 wsproto-1.2.0/tox.ini0000664000175000017500000000267000000000000015440 0ustar00ubuntuubuntu00000000000000[tox] envlist = py37, py38, py39, py310, pypy3, lint, docs, packaging [gh-actions] python = 3.7: py37 3.8: py38 3.9: py39 3.10: py310, lint, docs, packaging pypy3: pypy3 [testenv] passenv = GITHUB_* deps = pytest pytest-cov pytest-xdist commands = pytest --cov-report=xml --cov-report=term --cov=wsproto {posargs} [testenv:pypy3] # temporarily disable coverage testing on PyPy due to performance problems commands = pytest {posargs} [testenv:lint] deps = flake8 black isort mypy {[testenv]deps} commands = flake8 src/ test/ black --check --diff src/ test/ example/ compliance/ bench/ isort --check --diff src/ test/ example/ compliance/ bench/ mypy src/ test/ example/ bench/ [testenv:docs] deps = sphinx whitelist_externals = make changedir = {toxinidir}/docs commands = make clean make html [testenv:packaging] basepython = python3.10 deps = check-manifest readme-renderer twine whitelist_externals = rm commands = rm -rf dist/ check-manifest python setup.py sdist bdist_wheel twine check dist/* [testenv:publish] basepython = {[testenv:packaging]basepython} deps = {[testenv:packaging]deps} whitelist_externals = {[testenv:packaging]whitelist_externals} commands = {[testenv:packaging]commands} twine upload dist/* [testenv:autobahn] changedir = {toxinidir}/compliance commands = python run-autobahn-tests.py {env:SIDE:}