From 698d4e57354f300a85fcbb16ce1cb9c885b8b6b2 Mon Sep 17 00:00:00 2001 From: DHUKK Date: Tue, 17 Mar 2026 17:33:33 +0000 Subject: [PATCH 1/2] Rename ProtocolVersion to StartupRequestCode The startup packet's 4-byte field is a request code discriminator, not exclusively a protocol version. Renames the enum and updates version_code to request_code throughout registry, framing, and docs. --- docs/reference/constants.md | 4 +-- docs/reference/messages/startup.md | 2 +- src/pygwire/__init__.py | 4 +-- src/pygwire/constants.py | 11 ++++-- src/pygwire/framing.py | 14 ++++---- src/pygwire/messages/_registry.py | 26 +++++++------- src/pygwire/messages/_startup.py | 22 ++++++------ tests/integration/test_malformed_payloads.py | 8 ++--- tests/unit/test_framing.py | 14 ++++---- tests/unit/test_messages_startup.py | 38 ++++++++++---------- 10 files changed, 74 insertions(+), 69 deletions(-) diff --git a/docs/reference/constants.md b/docs/reference/constants.md index d29096f..4682eb0 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -33,9 +33,9 @@ Protocol-level enums and constants. | `FRONTEND` | `"frontend"` | Message sent by client | | `BACKEND` | `"backend"` | Message sent by server | -## `ProtocolVersion` +## `StartupRequestCode` -`IntEnum` of PostgreSQL protocol version codes. Used in `StartupMessage` and special request messages. +`IntEnum` of 32-bit codes sent in the startup packet request code field. Used in `StartupMessage` and special request messages. | Member | Value | Description | |--------|-------|-------------| diff --git a/docs/reference/messages/startup.md b/docs/reference/messages/startup.md index c239a8e..8d49302 100644 --- a/docs/reference/messages/startup.md +++ b/docs/reference/messages/startup.md @@ -18,7 +18,7 @@ Connection initialization. Sent as the first message from a client. | Field | Type | Description | |-------|------|-------------| | `params` | `dict[str, str]` | Key-value parameters (`user`, `database`, etc.) | -| `protocol_version` | `int` | Protocol version code (default: `ProtocolVersion.V3_0`) | +| `protocol_version` | `int` | Startup request code (default: `StartupRequestCode.V3_0`) | ```python from pygwire.messages import StartupMessage diff --git a/src/pygwire/__init__.py b/src/pygwire/__init__.py index db1f80d..3587741 100644 --- a/src/pygwire/__init__.py +++ b/src/pygwire/__init__.py @@ -8,7 +8,7 @@ Connection, FrontendConnection, ) -from pygwire.constants import ConnectionPhase, ProtocolVersion, TransactionStatus +from pygwire.constants import ConnectionPhase, StartupRequestCode, TransactionStatus from pygwire.exceptions import ( DecodingError, FramingError, @@ -35,7 +35,7 @@ "FrontendMessageDecoder", "FrontendStateMachine", "ProtocolError", - "ProtocolVersion", + "StartupRequestCode", "PygwireError", "StateMachineError", "TransactionStatus", diff --git a/src/pygwire/constants.py b/src/pygwire/constants.py index e267e9e..e0e27fe 100644 --- a/src/pygwire/constants.py +++ b/src/pygwire/constants.py @@ -3,7 +3,7 @@ __all__ = [ "ConnectionPhase", "MessageDirection", - "ProtocolVersion", + "StartupRequestCode", "TransactionStatus", ] @@ -80,8 +80,13 @@ class ConnectionPhase(Enum): FAILED = auto() -class ProtocolVersion(IntEnum): - """PostgreSQL Protocol Versions.""" +class StartupRequestCode(IntEnum): + """32-bit codes sent in the startup packet version field. + + The first 4 bytes of every startup packet are read as a request code. + V3_0 and V3_2 are actual protocol versions; SSL_REQUEST, GSSENC_REQUEST, + and CANCEL_REQUEST are magic numbers that share the same wire position. + """ V3_0 = 0x00030000 # Standard for PG 14-17 V3_2 = 0x00030002 # New for PG 18+ (Variable length cancel keys) diff --git a/src/pygwire/framing.py b/src/pygwire/framing.py index 0562532..0cbd047 100644 --- a/src/pygwire/framing.py +++ b/src/pygwire/framing.py @@ -97,12 +97,12 @@ class StartupFraming(FramingStrategy): Wire format: Bytes 0-3: Int32 length (including these 4 bytes) - Bytes 4-7: Int32 version_code (part of payload) + Bytes 4-7: Int32 request_code (part of payload) Bytes 8+: Remaining payload Example: - StartupMessage: length=52, version_code=0x00030000, params... - SSLRequest: length=8, version_code=80877103 + StartupMessage: length=52, request_code=0x00030000, params... + SSLRequest: length=8, request_code=80877103 """ def try_parse( @@ -129,13 +129,13 @@ def try_parse( payload_end = pos + length payload = buf[payload_start:payload_end] if len(payload) < 4: - raise FramingError("Startup message payload too short for version code") + raise FramingError("Startup message payload too short for request code") - (version_code,) = _LENGTH_STRUCT.unpack_from(payload) + (request_code,) = _LENGTH_STRUCT.unpack_from(payload) - msg_cls = STARTUP_REGISTRY.lookup(version_code) + msg_cls = STARTUP_REGISTRY.lookup(request_code) if msg_cls is None: - raise FramingError(f"Unknown startup message version code: {version_code:#010x}") + raise FramingError(f"Unknown startup message request code: {request_code:#010x}") try: msg = msg_cls.decode(payload) except struct.error as e: diff --git a/src/pygwire/messages/_registry.py b/src/pygwire/messages/_registry.py index 1213b63..c89abc6 100644 --- a/src/pygwire/messages/_registry.py +++ b/src/pygwire/messages/_registry.py @@ -1,11 +1,11 @@ """Message registry system for PostgreSQL wire protocol. This module provides the registry infrastructure that maps message identifiers -and version codes to message classes. It supports three types of registries +and request codes to message classes. It supports three types of registries for different framing modes: - StandardMessageRegistry: Standard framed messages (Byte1 + Int32 + payload) -- StartupMessageRegistry: Startup messages (Int32 + payload, version code discriminator) +- StartupMessageRegistry: Startup messages (Int32 + payload, request code discriminator) - NegotiationMessageRegistry: SSL/GSS negotiation (single byte messages) """ @@ -107,9 +107,9 @@ def lookup( class StartupMessageRegistry: - """Registry for startup messages (Int32 + payload, version code discriminator). + """Registry for startup messages (Int32 + payload, request code discriminator). - Startup messages have no identifier byte. Instead, they use a 4-byte version + Startup messages have no identifier byte. Instead, they use a 4-byte request code at the start of the payload to distinguish message types: - 0x00030000: StartupMessage - 80877103: SSLRequest @@ -120,38 +120,38 @@ class StartupMessageRegistry: """ def __init__(self) -> None: - # Key: version_code → message class + # Key: request_code → message class self._registry: dict[int, type[PGMessage]] = {} - def register(self, version_code: int) -> Callable[[type[PGMessage]], type[PGMessage]]: + def register(self, request_code: int) -> Callable[[type[PGMessage]], type[PGMessage]]: """Decorator to register a startup message class. Args: - version_code: 32-bit version/request code + request_code: 32-bit version/request code Example:: - @STARTUP_REGISTRY.register(version_code=0x00030000) + @STARTUP_REGISTRY.register(request_code=0x00030000) class StartupMessage(SpecialMessage): ... """ def decorator(cls: type[PGMessage]) -> type[PGMessage]: - self._registry[version_code] = cls + self._registry[request_code] = cls return cls return decorator - def lookup(self, version_code: int) -> type[PGMessage] | None: - """Find message class by version code. + def lookup(self, request_code: int) -> type[PGMessage] | None: + """Find message class by request code. Args: - version_code: 32-bit version/request code + request_code: 32-bit version/request code Returns: Message class or None if not found """ - return self._registry.get(version_code) + return self._registry.get(request_code) class NegotiationMessageRegistry: diff --git a/src/pygwire/messages/_startup.py b/src/pygwire/messages/_startup.py index b01afb8..a0a9374 100644 --- a/src/pygwire/messages/_startup.py +++ b/src/pygwire/messages/_startup.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from typing import Self -from pygwire.constants import ProtocolVersion +from pygwire.constants import StartupRequestCode from ._base import SpecialMessage, _read_cstring from ._registry import STARTUP_REGISTRY @@ -14,15 +14,15 @@ _INT32 = struct.Struct("!I") -@STARTUP_REGISTRY.register(version_code=ProtocolVersion.V3_0) -@STARTUP_REGISTRY.register(version_code=ProtocolVersion.V3_2) +@STARTUP_REGISTRY.register(request_code=StartupRequestCode.V3_0) +@STARTUP_REGISTRY.register(request_code=StartupRequestCode.V3_2) @dataclass(slots=True) class StartupMessage(SpecialMessage): """StartupMessage — initial connection packet (Protocol 3.0 & 3.2). Contains key-value parameters (user, database, options, etc.) terminated by a final null byte. The ``encode()`` method returns - the full payload including the Int32 version code. + the full payload including the Int32 request code. Note: The message format is identical in v3.0 and v3.2. Protocol version 3.2 (PostgreSQL 18+) only differs in CancelRequest and BackendKeyData @@ -30,7 +30,7 @@ class StartupMessage(SpecialMessage): """ params: dict[str, str] = field(default_factory=dict) - protocol_version: int = ProtocolVersion.V3_0 + protocol_version: int = StartupRequestCode.V3_0 def encode(self) -> bytes: buf = bytearray(_INT32.pack(self.protocol_version)) @@ -54,7 +54,7 @@ def decode(cls, payload: memoryview) -> Self: return cls(params=params, protocol_version=protocol_version) -@STARTUP_REGISTRY.register(version_code=ProtocolVersion.SSL_REQUEST) +@STARTUP_REGISTRY.register(request_code=StartupRequestCode.SSL_REQUEST) @dataclass(slots=True) class SSLRequest(SpecialMessage): """SSLRequest — asks if the server supports SSL. @@ -63,14 +63,14 @@ class SSLRequest(SpecialMessage): """ def encode(self) -> bytes: - return _INT32.pack(ProtocolVersion.SSL_REQUEST) + return _INT32.pack(StartupRequestCode.SSL_REQUEST) @classmethod def decode(cls, payload: memoryview) -> Self: return cls() -@STARTUP_REGISTRY.register(version_code=ProtocolVersion.GSSENC_REQUEST) +@STARTUP_REGISTRY.register(request_code=StartupRequestCode.GSSENC_REQUEST) @dataclass(slots=True) class GSSEncRequest(SpecialMessage): """GSSEncRequest — asks if the server supports GSS encryption. @@ -79,14 +79,14 @@ class GSSEncRequest(SpecialMessage): """ def encode(self) -> bytes: - return _INT32.pack(ProtocolVersion.GSSENC_REQUEST) + return _INT32.pack(StartupRequestCode.GSSENC_REQUEST) @classmethod def decode(cls, payload: memoryview) -> Self: return cls() -@STARTUP_REGISTRY.register(version_code=ProtocolVersion.CANCEL_REQUEST) +@STARTUP_REGISTRY.register(request_code=StartupRequestCode.CANCEL_REQUEST) @dataclass(slots=True) class CancelRequest(SpecialMessage): """CancelRequest — asks the server to cancel a running query. @@ -101,7 +101,7 @@ class CancelRequest(SpecialMessage): def encode(self) -> bytes: return ( - _INT32.pack(ProtocolVersion.CANCEL_REQUEST) + _INT32.pack(StartupRequestCode.CANCEL_REQUEST) + _INT32.pack(self.process_id) + self.secret_key ) diff --git a/tests/integration/test_malformed_payloads.py b/tests/integration/test_malformed_payloads.py index 7ebd37e..7ce5ca7 100644 --- a/tests/integration/test_malformed_payloads.py +++ b/tests/integration/test_malformed_payloads.py @@ -5,7 +5,7 @@ import pytest from pygwire.codec import BackendMessageDecoder, FrontendMessageDecoder -from pygwire.constants import ProtocolVersion +from pygwire.constants import StartupRequestCode from pygwire.exceptions import DecodingError, FramingError from pygwire.messages import ( AuthenticationSASL, @@ -165,7 +165,7 @@ def test_error_response_truncated_fields(self): next(decoder) def test_cancel_request_truncated(self): - """Test CancelRequest with valid version code but truncated payload. + """Test CancelRequest with valid request code but truncated payload. CancelRequest uses startup framing (no identifier byte). This exercises the StartupFraming struct.error catch path when decode fails on a @@ -176,8 +176,8 @@ def test_cancel_request_truncated(self): # CancelRequest wire format: Int32(length) + Int32(cancel_code) + Int32(pid) + secret_key # Craft a message with valid cancel code but truncated before process_id - cancel_code = int(ProtocolVersion.CANCEL_REQUEST) - payload = struct.pack("!I", cancel_code) # version code only, no pid/key + cancel_code = int(StartupRequestCode.CANCEL_REQUEST) + payload = struct.pack("!I", cancel_code) # request code only, no pid/key length = 4 + len(payload) # length includes itself wire = struct.pack("!I", length) + payload diff --git a/tests/unit/test_framing.py b/tests/unit/test_framing.py index 482f943..771805a 100644 --- a/tests/unit/test_framing.py +++ b/tests/unit/test_framing.py @@ -102,24 +102,24 @@ def test_message_exceeds_max_size(self): memoryview(wire), 0, ConnectionPhase.STARTUP, MessageDirection.FRONTEND ) - def test_payload_too_short_for_version_code(self): + def test_payload_too_short_for_request_code(self): """Test that payload shorter than 4 bytes raises FramingError.""" # Length = 5 (header) + 2 (payload) = 7, but payload needs 4 bytes for version wire = struct.pack("!I", 6) + b"ab" framing = StartupFraming() - with pytest.raises(FramingError, match="payload too short for version code"): + with pytest.raises(FramingError, match="payload too short for request code"): framing.try_parse( memoryview(wire), 0, ConnectionPhase.STARTUP, MessageDirection.FRONTEND ) - def test_unknown_version_code_raises_error(self): - """Test that unknown version code raises FramingError.""" - # Create message with invalid version code - wire = struct.pack("!II", 8, 0xDEADBEEF) # Invalid version code + def test_unknown_request_code_raises_error(self): + """Test that unknown request code raises FramingError.""" + # Create message with invalid request code + wire = struct.pack("!II", 8, 0xDEADBEEF) # Invalid request code framing = StartupFraming() - with pytest.raises(FramingError, match="Unknown startup message version code"): + with pytest.raises(FramingError, match="Unknown startup message request code"): framing.try_parse( memoryview(wire), 0, ConnectionPhase.STARTUP, MessageDirection.FRONTEND ) diff --git a/tests/unit/test_messages_startup.py b/tests/unit/test_messages_startup.py index 3208632..527656e 100644 --- a/tests/unit/test_messages_startup.py +++ b/tests/unit/test_messages_startup.py @@ -2,7 +2,7 @@ import pytest -from pygwire.constants import ProtocolVersion +from pygwire.constants import StartupRequestCode from pygwire.exceptions import DecodingError from pygwire.messages import ( CancelRequest, @@ -20,9 +20,9 @@ def test_encode_empty_params(self): msg = StartupMessage(params={}) wire = msg.encode() - # Should contain version code + final null terminator + # Should contain request code + final null terminator assert len(wire) >= 5 - assert wire[:4] == ProtocolVersion.V3_0.to_bytes(4, "big") + assert wire[:4] == StartupRequestCode.V3_0.to_bytes(4, "big") assert wire[-1:] == b"\x00" def test_encode_single_param(self): @@ -94,7 +94,7 @@ def test_round_trip(self): decoded = StartupMessage.decode(memoryview(wire)) assert decoded.params == original.params - assert decoded.protocol_version == ProtocolVersion.V3_0 # Default version + assert decoded.protocol_version == StartupRequestCode.V3_0 # Default version def test_unicode_in_params(self): """Test encoding/decoding with Unicode characters.""" @@ -129,7 +129,7 @@ def test_params_initialization(self): def test_decode_unterminated_string_raises_error(self): """Test that unterminated string raises DecodingError.""" # Create malformed payload: version + "user" without null terminator - wire = ProtocolVersion.V3_0.to_bytes(4, "big") + b"user" + wire = StartupRequestCode.V3_0.to_bytes(4, "big") + b"user" with pytest.raises(DecodingError, match="Unterminated string"): StartupMessage.decode(memoryview(wire)) @@ -154,7 +154,7 @@ def test_decode_v3_2_startup_message(self): # Create a v3.2 startup message manually (client would send this) params = {"user": "testuser", "database": "testdb"} buf = bytearray() - buf.extend(ProtocolVersion.V3_2.to_bytes(4, "big")) + buf.extend(StartupRequestCode.V3_2.to_bytes(4, "big")) for key, value in params.items(): buf.extend(key.encode("utf-8")) buf.append(0) @@ -165,15 +165,15 @@ def test_decode_v3_2_startup_message(self): # Decode should work since StartupMessage is registered for both v3.0 and v3.2 decoded = StartupMessage.decode(memoryview(buf)) assert decoded.params == params - assert decoded.protocol_version == ProtocolVersion.V3_2 + assert decoded.protocol_version == StartupRequestCode.V3_2 def test_encode_with_v3_2_protocol_version(self): """Test encoding StartupMessage with explicit v3.2 protocol version.""" - msg = StartupMessage(params={"user": "testuser"}, protocol_version=ProtocolVersion.V3_2) + msg = StartupMessage(params={"user": "testuser"}, protocol_version=StartupRequestCode.V3_2) wire = msg.encode() - # Should contain v3.2 version code - assert wire[:4] == ProtocolVersion.V3_2.to_bytes(4, "big") + # Should contain v3.2 request code + assert wire[:4] == StartupRequestCode.V3_2.to_bytes(4, "big") assert b"user\x00testuser\x00\x00" in wire def test_encode_defaults_to_v3_0(self): @@ -181,14 +181,14 @@ def test_encode_defaults_to_v3_0(self): msg = StartupMessage(params={"user": "testuser"}) wire = msg.encode() - # Should contain v3.0 version code by default - assert wire[:4] == ProtocolVersion.V3_0.to_bytes(4, "big") + # Should contain v3.0 request code by default + assert wire[:4] == StartupRequestCode.V3_0.to_bytes(4, "big") def test_v3_2_round_trip(self): """Test encode/decode round-trip with v3.2.""" original = StartupMessage( params={"user": "alice", "database": "production"}, - protocol_version=ProtocolVersion.V3_2, + protocol_version=StartupRequestCode.V3_2, ) wire = original.encode() @@ -196,7 +196,7 @@ def test_v3_2_round_trip(self): # Both params and protocol_version should be preserved assert decoded.params == original.params - assert decoded.protocol_version == ProtocolVersion.V3_2 + assert decoded.protocol_version == StartupRequestCode.V3_2 class TestSSLRequest: @@ -209,11 +209,11 @@ def test_encode(self): # Should be exactly 4 bytes (the SSL request code) assert len(wire) == 4 - assert wire == ProtocolVersion.SSL_REQUEST.to_bytes(4, "big") + assert wire == StartupRequestCode.SSL_REQUEST.to_bytes(4, "big") def test_decode(self): """Test decoding SSLRequest.""" - wire = ProtocolVersion.SSL_REQUEST.to_bytes(4, "big") + wire = StartupRequestCode.SSL_REQUEST.to_bytes(4, "big") decoded = SSLRequest.decode(memoryview(wire)) assert isinstance(decoded, SSLRequest) @@ -254,11 +254,11 @@ def test_encode(self): # Should be exactly 4 bytes (the GSSENC request code) assert len(wire) == 4 - assert wire == ProtocolVersion.GSSENC_REQUEST.to_bytes(4, "big") + assert wire == StartupRequestCode.GSSENC_REQUEST.to_bytes(4, "big") def test_decode(self): """Test decoding GSSEncRequest.""" - wire = ProtocolVersion.GSSENC_REQUEST.to_bytes(4, "big") + wire = StartupRequestCode.GSSENC_REQUEST.to_bytes(4, "big") decoded = GSSEncRequest.decode(memoryview(wire)) assert isinstance(decoded, GSSEncRequest) @@ -299,7 +299,7 @@ def test_encode_v3_0(self): # Should contain: cancel code (4) + process_id (4) + secret_key (4) assert len(wire) == 12 - assert wire[:4] == ProtocolVersion.CANCEL_REQUEST.to_bytes(4, "big") + assert wire[:4] == StartupRequestCode.CANCEL_REQUEST.to_bytes(4, "big") def test_encode_v3_2(self): """Test encoding CancelRequest with variable-length secret key (Protocol 3.2).""" From 34abd4494a28c2726aa7a3ceb297badabe1d81d0 Mon Sep 17 00:00:00 2001 From: DHUKK Date: Tue, 17 Mar 2026 18:37:11 +0000 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad35f74..e476665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `ProtocolVersion` enum renamed to `StartupRequestCode`. The `version_code` parameter on `StartupMessageRegistry.register()` and `.lookup()` is now `request_code`. + ## [0.1.0] - 2026-03-11 ### Added