Skip to content

Commit 2f34907

Browse files
Validate reserved fields on Header, DbResponse, EmptyResponse decode
LeaderRequest.decode_body validates its body-level reserved uint64 is zero; the corresponding Header, DbResponse, and EmptyResponse decoders did not — three reserved words the upstream C protocol pins to zero were silently accepted or not read at all. Asymmetry blocks using those bits as a future schema-version signal and hides peer corruption that should surface as a clean DecodeError. Apply symmetric strict validation: - Header.decode: reject non-zero `reserved` uint16 (message.h's `extra` field). - DbResponse.decode_body: require the full 8-byte body, read the trailing uint32 reserved field, reject non-zero. - EmptyResponse.decode_body: require the full 8-byte body, read the uint64 reserved, reject non-zero. Error wording mirrors LeaderRequest's phrasing ("<MessageName> reserved field must be 0, got {reserved}") for consistency. Updated tests: - TestHeaderReservedField: add test_decode_rejects_nonzero_reserved. - TestReservedFieldValidation (new): positive and negative cases for DbResponse and EmptyResponse. - test_decode_body_raises_on_short: DbResponse min body bumped from 4 to 8, EmptyResponse added (8). - The prior test_empty_response_accepts_any_body pinned the lax behaviour and is replaced by the new validation tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 156f3c8 commit 2f34907

4 files changed

Lines changed: 64 additions & 9 deletions

File tree

src/dqlitewire/messages/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ def decode(cls, data: bytes) -> "Header":
4747
size_words, msg_type, schema, reserved = struct.unpack("<IBBH", data[:HEADER_SIZE])
4848
except struct.error as e:
4949
raise DecodeError(f"Failed to decode header: {e}") from e
50+
# Upstream C (message.h) reserves the trailing uint16 and every
51+
# current server writes 0. Reject non-zero values so peer
52+
# corruption or a future schema extension surfaces as a clean
53+
# DecodeError instead of silently carrying bits we cannot
54+
# re-emit. Matches LeaderRequest.decode_body's strict check.
55+
if reserved != 0:
56+
raise DecodeError(f"Header reserved field must be 0, got {reserved}")
5057
return cls(size_words, msg_type, schema, reserved)
5158

5259
@property

src/dqlitewire/messages/responses.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,12 @@ def encode_body(self) -> bytes:
143143

144144
@classmethod
145145
def decode_body(cls, data: bytes, schema: int = 0) -> "DbResponse":
146+
if len(data) < 8:
147+
raise DecodeError(f"DbResponse body must be 8 bytes, got {len(data)}")
146148
db_id = decode_uint32(data)
149+
reserved = decode_uint32(data[4:])
150+
if reserved != 0:
151+
raise DecodeError(f"DbResponse reserved field must be 0, got {reserved}")
147152
return cls(db_id)
148153

149154

@@ -472,6 +477,11 @@ def encode_body(self) -> bytes:
472477

473478
@classmethod
474479
def decode_body(cls, data: bytes, schema: int = 0) -> "EmptyResponse":
480+
if len(data) < 8:
481+
raise DecodeError(f"EmptyResponse body must be 8 bytes, got {len(data)}")
482+
reserved = decode_uint64(data)
483+
if reserved != 0:
484+
raise DecodeError(f"EmptyResponse reserved field must be 0, got {reserved}")
475485
return cls()
476486

477487

tests/test_messages_requests.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,25 @@ def test_decoded_reserved_is_zero_after_encode_decode(self) -> None:
5050
header = Header.decode(msg.encode()[:HEADER_SIZE])
5151
assert header.reserved == 0
5252

53+
def test_decode_rejects_nonzero_reserved(self) -> None:
54+
"""Header.decode must reject non-zero reserved bytes.
55+
56+
The protocol spec pins the trailing uint16 at 0; a non-zero value
57+
signals peer corruption, a buggy encoder, or a future schema
58+
extension we do not understand. Reject it cleanly rather than
59+
silently carrying it past a boundary that can never re-emit it.
60+
"""
61+
import struct
62+
63+
import pytest
64+
65+
from dqlitewire.exceptions import DecodeError
66+
67+
# size_words=1, msg_type=0x42 (arbitrary), schema=0, reserved=0xBEEF
68+
encoded = struct.pack("<IBBH", 1, 0x42, 0, 0xBEEF)
69+
with pytest.raises(DecodeError, match="reserved field must be 0"):
70+
Header.decode(encoded)
71+
5372

5473
class TestLeaderRequest:
5574
def test_encode_has_body(self) -> None:

tests/test_messages_responses.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,7 +1294,8 @@ class TestShortBodyDecoding:
12941294
(WelcomeResponse, 8), # uint64 heartbeat_timeout
12951295
(MetadataResponse, 16), # 2 x uint64
12961296
(ResultResponse, 16), # 2 x uint64
1297-
(DbResponse, 4), # uint32 db_id
1297+
(DbResponse, 8), # uint32 db_id + uint32 reserved
1298+
(EmptyResponse, 8), # uint64 reserved
12981299
],
12991300
)
13001301
def test_decode_body_raises_on_short(self, cls: type, min_body: int) -> None:
@@ -1309,12 +1310,30 @@ def test_leader_response_legacy_missing_null_terminator_raises(self) -> None:
13091310
with pytest.raises(DecodeError):
13101311
LeaderResponse.decode_body_legacy(b"abc")
13111312

1312-
def test_empty_response_accepts_any_body(self) -> None:
1313-
"""``EmptyResponse`` is documented as having an 8-byte reserved
1314-
field but its decoder accepts any body length today. Pin the
1315-
current (lax) behaviour so any future tightening is a deliberate
1316-
decision — not a silent refactor side-effect.
1317-
"""
1318-
assert isinstance(EmptyResponse.decode_body(b""), EmptyResponse)
1313+
1314+
class TestReservedFieldValidation:
1315+
"""Non-zero reserved bytes must fail to decode across all message
1316+
types that declare a reserved field, matching LeaderRequest's
1317+
strict posture. Silently accepting non-zero reserved would hide
1318+
peer corruption and block reusing those bits for any future schema
1319+
signal.
1320+
"""
1321+
1322+
def test_db_response_rejects_nonzero_reserved(self) -> None:
1323+
# uint32 db_id + uint32 reserved (0xFFFFFFFF)
1324+
body = b"\x01\x00\x00\x00" + b"\xff\xff\xff\xff"
1325+
with pytest.raises(DecodeError, match="DbResponse reserved field must be 0"):
1326+
DbResponse.decode_body(body)
1327+
1328+
def test_db_response_accepts_zero_reserved(self) -> None:
1329+
# Round-trip form stays accepted.
1330+
body = b"\x01\x00\x00\x00" + b"\x00\x00\x00\x00"
1331+
assert DbResponse.decode_body(body).db_id == 1
1332+
1333+
def test_empty_response_rejects_nonzero_reserved(self) -> None:
1334+
body = b"\xff" * 8
1335+
with pytest.raises(DecodeError, match="EmptyResponse reserved field must be 0"):
1336+
EmptyResponse.decode_body(body)
1337+
1338+
def test_empty_response_accepts_zero_reserved(self) -> None:
13191339
assert isinstance(EmptyResponse.decode_body(b"\x00" * 8), EmptyResponse)
1320-
assert isinstance(EmptyResponse.decode_body(b"\xff" * 64), EmptyResponse)

0 commit comments

Comments
 (0)