Skip to content

Commit e45c920

Browse files
fix: defensive-copy _get_row_types fallback path
`RowsResponse._get_row_types` returned `self.column_types` by reference on the no-per-row-types fallback, punching a hole in the aliasing invariant that `__post_init__` establishes: a caller who captured the return value and mutated it would silently rewrite the message's private copy. Return a fresh `list(self.column_types)` instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 218a8d0 commit e45c920

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

src/dqlitewire/messages/responses.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,11 +245,18 @@ def __post_init__(self) -> None:
245245
self.column_types = list(self.column_types)
246246

247247
def _get_row_types(self, row_idx: int, row: list[Any]) -> list[ValueType]:
248-
"""Get types for a row: from row_types, column_types, or inferred."""
248+
"""Get types for a row: from row_types, column_types, or inferred.
249+
250+
The ``column_types`` fallback returns a fresh copy rather than
251+
``self.column_types`` itself, so that a caller who mutates the
252+
return value cannot silently rewrite the message's private
253+
copy. This preserves the aliasing invariant that
254+
``__post_init__`` establishes (issue 042, issue 052).
255+
"""
249256
if self.row_types and row_idx < len(self.row_types):
250257
return self.row_types[row_idx]
251258
if self.column_types:
252-
return self.column_types
259+
return list(self.column_types)
253260
# Infer from values
254261
from dqlitewire.types import encode_value
255262

tests/test_messages_responses.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,42 @@ def test_column_types_defensive_copy_on_construct(self) -> None:
231231
"RowsResponse.column_types must not alias the caller's list"
232232
)
233233

234+
def test_get_row_types_does_not_alias_column_types(self) -> None:
235+
"""Regression for issue 052.
236+
237+
``__post_init__`` defensively copies ``column_types`` so that
238+
mutation of a caller-supplied or decoder-supplied list cannot
239+
rewrite the ``RowsResponse``'s private copy. ``_get_row_types``
240+
used to return ``self.column_types`` by reference on the
241+
"no per-row types" fallback path, which silently punched a
242+
hole in that invariant: a caller who captured the return
243+
value and mutated it would mutate the message's private
244+
list.
245+
246+
This test asserts that the returned list is a distinct object
247+
from ``self.column_types`` and that mutating the return value
248+
does not affect the message.
249+
"""
250+
msg = RowsResponse(
251+
column_names=["x"],
252+
column_types=[ValueType.INTEGER],
253+
row_types=[],
254+
rows=[[1]],
255+
has_more=False,
256+
)
257+
258+
row_types = msg._get_row_types(0, [1])
259+
260+
assert row_types is not msg.column_types, (
261+
"_get_row_types must return a fresh list, not alias column_types"
262+
)
263+
264+
row_types.append(ValueType.BLOB)
265+
assert msg.column_types == [ValueType.INTEGER], (
266+
"mutating the _get_row_types return value must not affect "
267+
"msg.column_types (issue 042 invariant)"
268+
)
269+
234270

235271
class TestRowsResponse:
236272
def test_empty_result(self) -> None:

0 commit comments

Comments
 (0)