Skip to content

Commit b14bac1

Browse files
Fill wire test-coverage gaps (golden bytes, frame caps, row lengths)
- Add golden-byte tests for ConnectRequest, DumpRequest, ClusterRequest, TransferRequest, DescribeRequest, WeightRequest, and a with_params variant of QueryRequest. Hand-derived expected bytes catch symmetric encoder/decoder drift that round-trip tests cannot. Mirrors the pattern established by the Heartbeat / Files / Interrupt goldens from prior cycles. - Add tests for the "exceeds maximum possible in N bytes" frame-size cap on RowsResponse, FilesResponse, and ServersResponse. Each decoder already had the absolute cap tested; the secondary frame-size guard (count * SLOT_SIZE > remaining) was not fenced. - Add tests for encode_row_values' row/types length-mismatch EncodeError — a defensive raise in public tuples helper that had no direct test.
1 parent 177778e commit b14bac1

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

tests/test_decoder_caps.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Fence the secondary "exceeds maximum possible in N bytes" caps.
2+
3+
Each response decoder with a count field has a two-stage check:
4+
1. An absolute cap (``count > _MAX_FOO_COUNT``) — tested elsewhere.
5+
2. A frame-size cap (``count * SLOT_SIZE > remaining``) — rejects
6+
a count that is small enough to pass stage 1 but would require
7+
more bytes than the body holds (allocation-amplification attack).
8+
9+
Stage 2's raises had no tests before ISSUE-327. A regression that
10+
removes the frame-size check or reorders it with the absolute cap
11+
would ship silently.
12+
13+
Also fences ``encode_row_values``'s length-mismatch EncodeError
14+
(ISSUE-329) which had no direct test.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import pytest
20+
21+
from dqlitewire.constants import ValueType
22+
from dqlitewire.exceptions import DecodeError, EncodeError
23+
from dqlitewire.messages import FilesResponse, RowsResponse, ServersResponse
24+
from dqlitewire.tuples import encode_row_values
25+
from dqlitewire.types import encode_uint64
26+
27+
28+
class TestFrameSizeCaps:
29+
def test_servers_response_count_exceeds_remaining_bytes(self) -> None:
30+
"""count=1000 passes the absolute cap (max 10_000) but can't
31+
fit in a body of 8 bytes (just the count field itself).
32+
"""
33+
body = encode_uint64(1000)
34+
with pytest.raises(DecodeError, match="exceeds maximum possible"):
35+
ServersResponse.decode_body(body)
36+
37+
def test_files_response_count_exceeds_remaining_bytes(self) -> None:
38+
"""count=50 passes the absolute cap (max 100) but can't fit in
39+
a body of 8 bytes (each file requires ≥16 bytes).
40+
"""
41+
body = encode_uint64(50)
42+
with pytest.raises(DecodeError, match="exceeds maximum possible"):
43+
FilesResponse.decode_body(body)
44+
45+
def test_rows_response_count_exceeds_remaining_bytes(self) -> None:
46+
"""column_count=500 passes the absolute cap but cannot fit in a
47+
body smaller than column_count * 8 bytes.
48+
"""
49+
body = encode_uint64(500)
50+
with pytest.raises(DecodeError, match="exceeds maximum possible"):
51+
RowsResponse.decode_body(body)
52+
53+
54+
class TestEncodeRowValuesLengthMismatch:
55+
def test_more_values_than_types_rejected(self) -> None:
56+
with pytest.raises(EncodeError, match="Row values count"):
57+
encode_row_values([1, 2], [ValueType.INTEGER])
58+
59+
def test_fewer_values_than_types_rejected(self) -> None:
60+
with pytest.raises(EncodeError, match="Row values count"):
61+
encode_row_values([1], [ValueType.INTEGER, ValueType.INTEGER])

tests/test_golden_bytes.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,114 @@ def test_remove_request(self) -> None:
512512
assert isinstance(msg, RemoveRequest)
513513
assert msg.node_id == 3
514514

515+
def test_connect_request(self) -> None:
516+
"""ConnectRequest(node_id=2, address="127.0.0.1:9001"): type=11.
517+
518+
Body: uint64(2) + text("127.0.0.1:9001")
519+
text = 14 chars + null = 15 bytes, padded to 16
520+
Total body = 8 + 16 = 24 = 3 words
521+
"""
522+
from dqlitewire.messages import ConnectRequest
523+
524+
expected = _header(3, 11) + _u64(2) + _text("127.0.0.1:9001")
525+
assert encode_message(ConnectRequest(node_id=2, address="127.0.0.1:9001")) == expected
526+
527+
msg = decode_message(expected, is_request=True)
528+
assert isinstance(msg, ConnectRequest)
529+
assert msg.node_id == 2
530+
assert msg.address == "127.0.0.1:9001"
531+
532+
def test_dump_request(self) -> None:
533+
"""DumpRequest(name="test.db"): type=15.
534+
535+
Body: text("test.db") = 7 chars + null = 8 bytes (exact word).
536+
Total body = 8 = 1 word
537+
"""
538+
from dqlitewire.messages import DumpRequest
539+
540+
expected = _header(1, 15) + _text("test.db")
541+
assert encode_message(DumpRequest(name="test.db")) == expected
542+
543+
msg = decode_message(expected, is_request=True)
544+
assert isinstance(msg, DumpRequest)
545+
assert msg.name == "test.db"
546+
547+
def test_cluster_request(self) -> None:
548+
"""ClusterRequest(format=1): type=16.
549+
550+
Body: uint64(1) = 8 bytes = 1 word.
551+
"""
552+
from dqlitewire.messages import ClusterRequest
553+
554+
expected = _header(1, 16) + _u64(1)
555+
assert encode_message(ClusterRequest(format=1)) == expected
556+
557+
msg = decode_message(expected, is_request=True)
558+
assert isinstance(msg, ClusterRequest)
559+
assert msg.format == 1
560+
561+
def test_transfer_request(self) -> None:
562+
"""TransferRequest(target_node_id=7): type=17.
563+
564+
Body: uint64(7) = 8 bytes = 1 word.
565+
"""
566+
from dqlitewire.messages import TransferRequest
567+
568+
expected = _header(1, 17) + _u64(7)
569+
assert encode_message(TransferRequest(target_node_id=7)) == expected
570+
571+
msg = decode_message(expected, is_request=True)
572+
assert isinstance(msg, TransferRequest)
573+
assert msg.target_node_id == 7
574+
575+
def test_describe_request(self) -> None:
576+
"""DescribeRequest(format=0): type=18.
577+
578+
Body: uint64(0) = 8 bytes = 1 word. Upstream defines only V0=0.
579+
"""
580+
from dqlitewire.messages import DescribeRequest
581+
582+
expected = _header(1, 18) + _u64(0)
583+
assert encode_message(DescribeRequest(format=0)) == expected
584+
585+
msg = decode_message(expected, is_request=True)
586+
assert isinstance(msg, DescribeRequest)
587+
assert msg.format == 0
588+
589+
def test_weight_request(self) -> None:
590+
"""WeightRequest(weight=5): type=19.
591+
592+
Body: uint64(5) = 8 bytes = 1 word.
593+
"""
594+
from dqlitewire.messages import WeightRequest
595+
596+
expected = _header(1, 19) + _u64(5)
597+
assert encode_message(WeightRequest(weight=5)) == expected
598+
599+
msg = decode_message(expected, is_request=True)
600+
assert isinstance(msg, WeightRequest)
601+
assert msg.weight == 5
602+
603+
def test_query_request_with_params(self) -> None:
604+
"""QueryRequest(db_id=1, stmt_id=2, params=[42, "hello"]): type=6, schema=0.
605+
606+
Mirror of test_exec_request_with_params but for QUERY. Pins the
607+
shared tuples-layout encoding under a different MSG_TYPE so a
608+
regression specific to the query path does not slip past.
609+
"""
610+
params_header = bytes([0x02, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00])
611+
expected = (
612+
_header(4, 6, schema=0) + _u32(1) + _u32(2) + params_header + _i64(42) + _text("hello")
613+
)
614+
msg = QueryRequest(db_id=1, stmt_id=2, params=[42, "hello"])
615+
assert encode_message(msg) == expected
616+
617+
decoded = decode_message(expected, is_request=True)
618+
assert isinstance(decoded, QueryRequest)
619+
assert decoded.db_id == 1
620+
assert decoded.stmt_id == 2
621+
assert decoded.params == [42, "hello"]
622+
515623

516624
class TestGoldenResponsesPriority1:
517625
"""Golden byte tests for high-priority response messages."""

0 commit comments

Comments
 (0)