Skip to content

Commit 0f2e583

Browse files
Validate the full 8-byte end marker in zero-column RowsResponse decoding
The decoder's fast path for column_count == 0 checked only the first byte of the end-of-rows sentinel (0xff for DONE, 0xee for PART) and accepted any 7 trailing bytes. The non-zero-column path goes through decode_row_header, which validates the full 8-byte DQLITE_RESPONSE_ROWS_DONE / _PART word, so a torn or corrupt marker like 0xff 0x00..0x00 was silently accepted on the zero-column path while being rejected on the normal path. A legitimate peer always emits the full sentinel; treating a torn shape as valid desynchronises the stream and risks returning a wrong has_more value on zero-column results. Use the same full-word compare against _ROW_DONE_MARKER / _ROW_PART_MARKER on both paths and drop the now-unused ROW_DONE_BYTE / ROW_PART_BYTE imports. Regression tests cover both torn DONE and torn PART shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee92105 commit 0f2e583

2 files changed

Lines changed: 29 additions & 6 deletions

File tree

src/dqlitewire/messages/responses.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
from typing import Any, ClassVar
55

66
from dqlitewire.constants import (
7-
ROW_DONE_BYTE,
87
ROW_DONE_MARKER,
9-
ROW_PART_BYTE,
108
ROW_PART_MARKER,
119
WORD_SIZE,
1210
ResponseType,
@@ -15,6 +13,8 @@
1513
from dqlitewire.exceptions import DecodeError, EncodeError
1614
from dqlitewire.messages.base import Message
1715
from dqlitewire.tuples import (
16+
_ROW_DONE_MARKER,
17+
_ROW_PART_MARKER,
1818
RowMarker,
1919
decode_row_header,
2020
decode_row_values,
@@ -369,19 +369,23 @@ def decode_body(
369369

370370
# Zero-column results cannot have row data (each row would be zero
371371
# bytes), so skip the row loop and consume the end marker directly.
372+
# Validate the full 8-byte sentinel against DQLITE_RESPONSE_ROWS_DONE
373+
# / _PART, matching the non-zero path (which goes through
374+
# decode_row_header). A first-byte-only compare would silently accept
375+
# torn markers like ``0xff 0x00..``.
372376
if column_count == 0:
373377
if offset + WORD_SIZE > len(view):
374378
raise DecodeError(
375379
"RowsResponse body exhausted without end marker (zero-column result)"
376380
)
377-
marker_byte = view[offset]
378-
if marker_byte == ROW_DONE_BYTE:
381+
marker = bytes(view[offset : offset + WORD_SIZE])
382+
if marker == _ROW_DONE_MARKER:
379383
has_more = False
380-
elif marker_byte == ROW_PART_BYTE:
384+
elif marker == _ROW_PART_MARKER:
381385
has_more = True
382386
else:
383387
raise DecodeError(
384-
f"Expected DONE or PART marker for zero-column result, got 0x{marker_byte:02x}"
388+
f"Expected DONE or PART marker for zero-column result, got 0x{marker.hex()}"
385389
)
386390
return cls(
387391
column_names=[],

tests/test_messages_responses.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,25 @@ def test_zero_columns_missing_marker_raises(self) -> None:
503503
with pytest.raises(DecodeError, match="end marker"):
504504
RowsResponse.decode_body(data)
505505

506+
def test_zero_columns_rejects_torn_done_marker(self) -> None:
507+
"""Zero-column result with only the first byte of DONE must be
508+
rejected — the non-zero-column path rejects the same torn shape
509+
via decode_row_header, and both paths should be symmetric."""
510+
from dqlitewire.types import encode_uint64
511+
512+
# column_count=0, followed by 0xff + 7 zero bytes (torn marker).
513+
data = encode_uint64(0) + b"\xff" + b"\x00" * 7
514+
with pytest.raises(DecodeError, match="marker"):
515+
RowsResponse.decode_body(data)
516+
517+
def test_zero_columns_rejects_torn_part_marker(self) -> None:
518+
"""Zero-column result with only the first byte of PART must be rejected."""
519+
from dqlitewire.types import encode_uint64
520+
521+
data = encode_uint64(0) + b"\xee" + b"\x00" * 7
522+
with pytest.raises(DecodeError, match="marker"):
523+
RowsResponse.decode_body(data)
524+
506525
def test_decode_body_rejects_non_list_row_header(self) -> None:
507526
"""decode_body must raise DecodeError if decode_row_header returns unexpected type."""
508527
from unittest.mock import patch

0 commit comments

Comments
 (0)