Skip to content

Commit 3e0e6c8

Browse files
fix: make MessageDecoder.decode_bytes honor the poison flag
MessageDecoder.decode() runs through a poison gate before calling self.decode_bytes(). But decode_bytes() is itself a public method that a user might reach directly — for instance, when catching a ProtocolError from decode() and trying to "resume" by feeding the raw bytes to decode_bytes() — bypassing the poison contract entirely. Once the buffer is poisoned, any public entry point that consumes bytes and runs parser code must refuse to run. Add self._buffer._check_poisoned() as the first statement of decode_bytes(). The decode_message() convenience function is unaffected: it creates a fresh MessageDecoder on every call, so the gate can never fire there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c9201a1 commit 3e0e6c8

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

src/dqlitewire/codec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,10 @@ def decode(self) -> Message | None:
285285
def decode_bytes(self, data: bytes) -> Message:
286286
"""Decode a message from bytes.
287287
288+
Raises ProtocolError if the decoder is poisoned.
288289
Raises ProtocolError if called on a request decoder before decode_handshake().
289290
"""
291+
self._buffer._check_poisoned()
290292
if not self._handshake_done:
291293
raise ProtocolError(
292294
"Protocol handshake not yet received. "

tests/test_codec.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,37 @@ def test_decode_continuation_poisons_on_wrong_type(self) -> None:
11411141
with pytest.raises(ProtocolError, match="poisoned"):
11421142
decoder.decode()
11431143

1144+
def test_decode_bytes_honors_poison(self) -> None:
1145+
"""Regression for issue 039.
1146+
1147+
``MessageDecoder.decode_bytes`` is a public method that parses a
1148+
single caller-supplied bytes object. ``decode()`` goes through a
1149+
poison gate before calling ``decode_bytes``, but a user who
1150+
catches ``ProtocolError`` from ``decode()`` and tries to
1151+
"resume" by feeding the raw bytes to ``decode_bytes`` directly
1152+
bypasses the poison contract entirely. Any public entry point
1153+
that consumes bytes and runs parser code must refuse to run on
1154+
a poisoned decoder; ``decode_bytes`` was the last gap.
1155+
"""
1156+
from dqlitewire.exceptions import ProtocolError
1157+
1158+
decoder = MessageDecoder(is_request=False)
1159+
decoder._buffer.poison(DecodeError("original cause"))
1160+
1161+
# Build a well-formed message that would otherwise decode fine.
1162+
msg = LeaderResponse(node_id=1, address="x:1").encode()
1163+
with pytest.raises(ProtocolError, match="poisoned") as ei:
1164+
decoder.decode_bytes(msg)
1165+
assert isinstance(ei.value.__cause__, DecodeError)
1166+
1167+
# decode_message() helper creates a fresh decoder on every call,
1168+
# so it must still work even though the dispatch name overlaps.
1169+
from dqlitewire.codec import decode_message
1170+
1171+
fresh = decode_message(msg, is_request=False)
1172+
assert isinstance(fresh, LeaderResponse)
1173+
assert fresh.node_id == 1
1174+
11441175
def test_poison_is_first_error_wins(self) -> None:
11451176
"""ReadBuffer.poison() must NOT overwrite an existing poison error.
11461177
First error wins so the original __cause__ remains visible.

0 commit comments

Comments
 (0)