Skip to content

Commit e1b4428

Browse files
Reject trailing bytes in StmtResponse body decode
StmtResponse was the last fixed-body response still accepting oversized bodies via a minimum-length check. Every sibling (DbResponse, EmptyResponse, ResultResponse, FilesResponse) already enforces exact length. Align on the strict-decode policy: schema-0 bodies must be exactly 16 bytes, schema-1 bodies exactly 24 bytes — extra trailing bytes are now refused rather than silently discarded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b14bac1 commit e1b4428

4 files changed

Lines changed: 78 additions & 14 deletions

File tree

src/dqlitewire/messages/responses.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,10 @@ def encode_body(self) -> bytes:
261261
@classmethod
262262
def decode_body(cls, data: bytes, schema: int = 0) -> "StmtResponse":
263263
expected = 24 if schema >= 1 else 16
264-
if len(data) < expected:
264+
if len(data) != expected:
265265
raise DecodeError(
266-
f"StmtResponse schema={schema} requires at least {expected} bytes, got {len(data)}"
266+
f"StmtResponse schema={schema} body must be exactly {expected} bytes, "
267+
f"got {len(data)}"
267268
)
268269
db_id = decode_uint32(data)
269270
stmt_id = decode_uint32(data[4:])

tests/test_codec.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -999,20 +999,25 @@ def test_max_uint64_roundtrip(self) -> None:
999999
assert isinstance(decoded, ClientRequest)
10001000
assert decoded.client_id == 2**64 - 1
10011001

1002-
def test_stmt_response_v0_with_trailing_data_not_detected_as_v1(self) -> None:
1003-
"""StmtResponse V0 with trailing bytes must not be misdetected as V1."""
1002+
def test_stmt_response_v0_with_trailing_data_rejected(self) -> None:
1003+
"""StmtResponse V0 with trailing bytes must be rejected outright.
1004+
1005+
Previously the schema-aware decode only prevented mis-detection
1006+
as V1 (tail_offset was forced None). Under strict-length semantics
1007+
a 24-byte body under schema=0 is itself invalid — the Go/C
1008+
servers never emit trailing padding on fixed-body responses, and
1009+
accepting it silently is the same "two states collapsed into one"
1010+
bug that schema-awareness was meant to fix.
1011+
"""
1012+
from dqlitewire.exceptions import DecodeError
10041013
from dqlitewire.messages.base import Header
10051014
from dqlitewire.types import encode_uint32, encode_uint64
10061015

1007-
# Build a V0 body (16 bytes) + 8 extra trailing bytes (total 24)
1008-
# Without schema awareness, len >= 24 triggers V1 decode reading garbage tail_offset
10091016
body = encode_uint32(1) + encode_uint32(2) + encode_uint64(3) + b"\x00" * 8
10101017
header = Header(size_words=3, msg_type=5, schema=0)
10111018
data = header.encode() + body
1012-
decoded = decode_message(data, is_request=False)
1013-
assert isinstance(decoded, StmtResponse)
1014-
# With schema=0 in header, tail_offset should NOT be decoded
1015-
assert decoded.tail_offset is None
1019+
with pytest.raises(DecodeError, match=r"schema=0 body must be exactly 16 bytes"):
1020+
decode_message(data, is_request=False)
10161021

10171022

10181023
class TestDecoderContinuation:

tests/test_messages_responses.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ def test_v0_has_no_tail_offset(self) -> None:
205205
assert decoded.tail_offset is None
206206

207207
def test_schema_0_rejects_short_body(self) -> None:
208-
"""235: Schema=0 body must be at least 16 bytes."""
209-
with pytest.raises(DecodeError, match="requires at least 16 bytes"):
208+
"""235: Schema=0 body must be exactly 16 bytes."""
209+
with pytest.raises(DecodeError, match=r"schema=0 body must be exactly 16 bytes"):
210210
StmtResponse.decode_body(b"\x00" * 8, schema=0)
211211

212212
def test_schema_1_rejects_v0_length_body(self) -> None:
@@ -218,7 +218,7 @@ def test_schema_1_rejects_v0_length_body(self) -> None:
218218
"""
219219
v0_body = b"\x01\x00\x00\x00" + b"\x02\x00\x00\x00" + b"\x03" + b"\x00" * 7
220220
assert len(v0_body) == 16
221-
with pytest.raises(DecodeError, match="requires at least 24 bytes"):
221+
with pytest.raises(DecodeError, match=r"schema=1 body must be exactly 24 bytes"):
222222
StmtResponse.decode_body(v0_body, schema=1)
223223

224224
def test_rejects_oversized_num_params(self) -> None:

tests/test_responses_strict_length.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515

1616
from dqlitewire.exceptions import DecodeError
17-
from dqlitewire.messages.responses import DbResponse, EmptyResponse, ResultResponse
17+
from dqlitewire.messages.responses import DbResponse, EmptyResponse, ResultResponse, StmtResponse
1818

1919

2020
class TestEmptyResponseStrictLength:
@@ -78,3 +78,61 @@ def test_exact_length_accepted(self) -> None:
7878
def test_trailing_bytes_rejected(self) -> None:
7979
with pytest.raises(DecodeError, match="ResultResponse body must be exactly 16 bytes"):
8080
ResultResponse.decode_body(b"\x00" * 17)
81+
82+
83+
class TestStmtResponseStrictLength:
84+
"""StmtResponse carries db_id + stmt_id + num_params (+ optional
85+
tail_offset for schema>=1). Body is exactly 16 bytes (schema 0) or
86+
24 bytes (schema 1). Trailing bytes had been silently accepted;
87+
sibling responses reject them.
88+
"""
89+
90+
@staticmethod
91+
def _body_schema0(db_id: int = 1, stmt_id: int = 42, num_params: int = 3) -> bytes:
92+
return (
93+
db_id.to_bytes(4, "little")
94+
+ stmt_id.to_bytes(4, "little")
95+
+ num_params.to_bytes(8, "little")
96+
)
97+
98+
@staticmethod
99+
def _body_schema1(
100+
db_id: int = 1, stmt_id: int = 42, num_params: int = 3, tail_offset: int = 0
101+
) -> bytes:
102+
return TestStmtResponseStrictLength._body_schema0(
103+
db_id, stmt_id, num_params
104+
) + tail_offset.to_bytes(8, "little")
105+
106+
def test_short_body_rejected_schema0(self) -> None:
107+
with pytest.raises(DecodeError, match=r"StmtResponse schema=0 body must be exactly 16"):
108+
StmtResponse.decode_body(b"\x00" * 15, schema=0)
109+
110+
def test_exact_length_accepted_schema0(self) -> None:
111+
msg = StmtResponse.decode_body(self._body_schema0(), schema=0)
112+
assert msg.db_id == 1
113+
assert msg.stmt_id == 42
114+
assert msg.num_params == 3
115+
assert msg.tail_offset is None
116+
117+
def test_trailing_bytes_rejected_schema0(self) -> None:
118+
"""Previous decoder silently accepted extra bytes; must now
119+
raise so a conforming StmtResponse round-trips exactly."""
120+
body = self._body_schema0() + b"\x01"
121+
with pytest.raises(DecodeError, match=r"StmtResponse schema=0 body must be exactly 16"):
122+
StmtResponse.decode_body(body, schema=0)
123+
124+
def test_short_body_rejected_schema1(self) -> None:
125+
with pytest.raises(DecodeError, match=r"StmtResponse schema=1 body must be exactly 24"):
126+
StmtResponse.decode_body(b"\x00" * 23, schema=1)
127+
128+
def test_exact_length_accepted_schema1(self) -> None:
129+
msg = StmtResponse.decode_body(self._body_schema1(tail_offset=7), schema=1)
130+
assert msg.db_id == 1
131+
assert msg.stmt_id == 42
132+
assert msg.num_params == 3
133+
assert msg.tail_offset == 7
134+
135+
def test_trailing_bytes_rejected_schema1(self) -> None:
136+
body = self._body_schema1() + b"\x02"
137+
with pytest.raises(DecodeError, match=r"StmtResponse schema=1 body must be exactly 24"):
138+
StmtResponse.decode_body(body, schema=1)

0 commit comments

Comments
 (0)