Skip to content

Commit cd778d2

Browse files
Pin decoder rejection of oversize continuation frames
ReadBuffer applies max_message_size uniformly on every read regardless of frame ordinal, but no test pinned that invariant for mid-stream ROWS continuation frames. The new tests feed a valid first frame, then feed a continuation header whose declared body exceeds the buffer cap, and assert both the DecodeError on the next decode_continuation() call and the PoisonedError on any subsequent decode attempt — the continuation path has no skip_message() recovery by design, so the poison-then-reset flow is load-bearing. A matching within-cap test fences the boundary against an overly eager cap that refuses everything once continuation mode is entered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cdb5ee8 commit cd778d2

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Oversize continuation frames must be rejected before a body read.
2+
3+
``ReadBuffer`` enforces ``max_message_size`` uniformly on every
4+
``feed()`` / ``read_message()`` call regardless of frame ordinal —
5+
there is no "first vs. continuation" distinction in the buffer
6+
layer. This module is a structural regression fence: a future
7+
refactor that split the cap check into a "first-frame only" branch,
8+
added a per-frame counter with an off-by-one, or weakened the cap
9+
during continuation mode would silently re-open an amplification
10+
channel. The tests below pin the contract from the MessageDecoder
11+
call-site that an in-progress continuation respects the cap.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import cast
17+
18+
import pytest
19+
20+
from dqlitewire.buffer import HEADER_SIZE, WORD_SIZE
21+
from dqlitewire.codec import MessageDecoder
22+
from dqlitewire.constants import ResponseType, ValueType
23+
from dqlitewire.exceptions import DecodeError, PoisonedError
24+
from dqlitewire.messages.responses import RowsResponse
25+
26+
27+
def _fabricate_oversize_rows_header(body_words: int) -> bytes:
28+
"""Return a valid header that claims a body of ``body_words`` WORDs
29+
plus enough trailing garbage bytes so the buffer would accept the
30+
frame if no cap check fired. The body itself is never read — the
31+
cap check on ``feed()`` triggers first.
32+
"""
33+
# Header layout: uint32 size_in_words | u8 type | u8 schema | u16 extra.
34+
header = (
35+
body_words.to_bytes(4, "little")
36+
+ int(ResponseType.ROWS).to_bytes(1, "little")
37+
+ (0).to_bytes(1, "little")
38+
+ (0).to_bytes(2, "little")
39+
)
40+
assert len(header) == HEADER_SIZE
41+
# A tiny body tail — the buffer rejects before reading this; we
42+
# only need enough bytes so the buffer sees a complete-ish frame
43+
# arriving. ``feed()`` raises on size projection, not on content.
44+
return header + b"\x00" * 8
45+
46+
47+
class TestContinuationFrameOverCap:
48+
"""Cap check must apply to continuation frames identically to
49+
the initial frame."""
50+
51+
def test_continuation_header_over_cap_rejected(self) -> None:
52+
"""After a valid first ROWS frame with has_more=True, a
53+
continuation header declaring a body over the buffer cap must
54+
raise DecodeError once the decoder attempts to consume it.
55+
The continuation path does not have ``skip_message()`` recovery,
56+
so the decoder poisons and forces the caller to reset().
57+
"""
58+
small_cap = 4096
59+
decoder = MessageDecoder(max_message_size=small_cap)
60+
61+
# First frame: valid, has_more=True.
62+
first = RowsResponse(
63+
column_names=["a"],
64+
column_types=[ValueType.INTEGER],
65+
row_types=[[ValueType.INTEGER]],
66+
rows=[[1]],
67+
has_more=True,
68+
)
69+
first_bytes = first.encode()
70+
assert len(first_bytes) < small_cap
71+
72+
decoder.feed(first_bytes)
73+
result = cast(RowsResponse, decoder.decode())
74+
assert result is not None
75+
assert result.has_more is True
76+
77+
# Continuation header that claims a body size well over the cap.
78+
# The header+tail is only a handful of bytes so ``feed()`` accepts
79+
# it (total projected size is tiny) — the cap check fires when
80+
# ``decode_continuation()`` attempts to read the declared frame.
81+
oversize_body_words = (small_cap // WORD_SIZE) + 32
82+
oversize_frame = _fabricate_oversize_rows_header(oversize_body_words)
83+
decoder.feed(oversize_frame)
84+
85+
with pytest.raises(DecodeError, match=r"exceeds maximum"):
86+
decoder.decode_continuation()
87+
88+
def test_continuation_within_cap_decodes(self) -> None:
89+
"""A legitimate continuation frame under the cap must decode
90+
cleanly — boundary fence against an over-eager cap that
91+
rejects anything in continuation mode.
92+
"""
93+
small_cap = 64 * 1024
94+
decoder = MessageDecoder(max_message_size=small_cap)
95+
96+
first = RowsResponse(
97+
column_names=["a"],
98+
column_types=[ValueType.INTEGER],
99+
row_types=[[ValueType.INTEGER]],
100+
rows=[[1]],
101+
has_more=True,
102+
)
103+
decoder.feed(first.encode())
104+
assert decoder.decode() is not None
105+
106+
# Second frame: small, has_more=False to terminate the stream.
107+
second = RowsResponse(
108+
column_names=["a"],
109+
column_types=[ValueType.INTEGER],
110+
row_types=[[ValueType.INTEGER]],
111+
rows=[[2]],
112+
has_more=False,
113+
)
114+
decoder.feed(second.encode())
115+
cont = decoder.decode_continuation()
116+
assert cont is not None
117+
assert cont.has_more is False
118+
assert cont.rows == [[2]]
119+
120+
121+
class TestContinuationPoisonedAfterOversize:
122+
"""After an oversize continuation rejection, further decode calls
123+
must raise PoisonedError — the buffer has no safe recovery path
124+
during continuation mode."""
125+
126+
def test_poisoned_after_oversize_continuation(self) -> None:
127+
small_cap = 4096
128+
decoder = MessageDecoder(max_message_size=small_cap)
129+
130+
first = RowsResponse(
131+
column_names=["a"],
132+
column_types=[ValueType.INTEGER],
133+
row_types=[[ValueType.INTEGER]],
134+
rows=[[1]],
135+
has_more=True,
136+
)
137+
decoder.feed(first.encode())
138+
decoder.decode()
139+
140+
oversize_body_words = (small_cap // WORD_SIZE) + 32
141+
oversize_frame = _fabricate_oversize_rows_header(oversize_body_words)
142+
decoder.feed(oversize_frame)
143+
144+
with pytest.raises(DecodeError):
145+
decoder.decode_continuation()
146+
147+
# Further operations must see the poisoned state — the
148+
# continuation path cannot recover in-place via skip_message().
149+
with pytest.raises(PoisonedError):
150+
decoder.decode_continuation()

0 commit comments

Comments
 (0)