Skip to content

Commit 5d0d819

Browse files
Raise _MAX_COLUMN_COUNT to SQLite's documented default
The C server emits sqlite3_column_count(stmt) as uint64 without cap (query.c:111-120); STMT__MAX_COLUMNS in stmt.c:10 is defined but never referenced anywhere in the C tree, so the Python-side cap is defence-in-depth against pathological emissions rather than a wire-protocol mirror. SQLite's compile-time SQLITE_MAX_COLUMN default is 2000; analytical / feature-store wide-table SELECTs legitimately cross the prior 255 cap. Raise to 2000 so legitimate frames decode while still rejecting absurd peer emissions. The per-name cap (_MAX_COLUMN_NAME_SIZE = 4096) and frame-envelope cap already bound memory growth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 920e4df commit 5d0d819

3 files changed

Lines changed: 75 additions & 38 deletions

File tree

src/dqlitewire/messages/responses.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,26 @@
5858

5959
logger = logging.getLogger(__name__)
6060

61-
# Defense-in-depth upper bounds for count fields in response messages.
62-
# Tightened to dqlite's actual emission ceiling: the C server's
63-
# ``stmt.c:10`` defines ``STMT__MAX_COLUMNS = (1 << 8) - 1 = 255``
64-
# with the explicit comment "fits in one byte". Any column count
65-
# above 255 from a real cluster is provably malformed.
61+
# Defense-in-depth upper bound on the column count in
62+
# ``RowsResponse``. The C server's ``query.c:111-120`` calls
63+
# ``sqlite3_column_count(stmt)`` and encodes the result as
64+
# ``uint64`` WITHOUT a wire-protocol cap; the
65+
# ``STMT__MAX_COLUMNS`` macro in ``stmt.c:10`` is defined but
66+
# never referenced (verified by grep across the canonical/dqlite
67+
# C tree). The Python-side cap is therefore defence-in-depth
68+
# against pathological peer emissions, not a wire-protocol mirror.
6669
#
67-
# SQLite itself documents ``SQLITE_MAX_COLUMN = 32767`` as the
68-
# absolute build-time maximum (https://www.sqlite.org/limits.html),
69-
# but dqlite never approaches it. Capping at 255 closes a
70-
# defence-in-depth amplification window: a hostile peer emitting
71-
# ``column_count = 32766`` would force ~32k empty-string
72-
# allocations before per-string size caps kick in.
73-
_MAX_COLUMN_COUNT: Final[int] = 255
70+
# SQLite's compile-time ``SQLITE_MAX_COLUMN`` default is 2000
71+
# (raisable to 32767 via build flag); a wide-table SELECT against
72+
# an analytics / feature-store schema legitimately crosses the
73+
# prior 255 cap. Cap at SQLite's documented default so legitimate
74+
# frames decode while still rejecting absurd peer emissions. The
75+
# per-name cap (``_MAX_COLUMN_NAME_SIZE = 4096``) and the frame-
76+
# envelope cap (default 64 MiB) already bound memory growth from
77+
# the N × name allocation.
78+
#
79+
# Reference: https://www.sqlite.org/limits.html#max_column.
80+
_MAX_COLUMN_COUNT: Final[int] = 2000
7481
_MAX_FILE_COUNT: Final[int] = 100
7582
# Defence-in-depth cap on the ServersResponse node count. Upstream
7683
# (C ``gateway.c::handle_cluster`` reads ``response.n =

tests/test_constants.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -465,13 +465,16 @@ def test_max_param_count_matches_sqlite_max_variable_number(self) -> None:
465465
# intermediate allocations.
466466
assert _MAX_PARAM_COUNT == 32_766
467467

468-
def test_max_column_count_matches_dqlite_stmt_max(self) -> None:
468+
def test_max_column_count_matches_sqlite_default(self) -> None:
469469
from dqlitewire.messages.responses import _MAX_COLUMN_COUNT
470470

471-
# The C server's ``STMT__MAX_COLUMNS = (1 << 8) - 1 = 255``
472-
# is the actual emission ceiling. Any column count above 255
473-
# from a real dqlite cluster is provably malformed.
474-
assert _MAX_COLUMN_COUNT == 255
471+
# SQLite's compile-time ``SQLITE_MAX_COLUMN`` default is 2000.
472+
# The C server's ``query.c:111-120`` emits the column count
473+
# as ``uint64`` without cap; ``STMT__MAX_COLUMNS`` in
474+
# ``stmt.c:10`` is defined but never referenced, so the
475+
# Python cap is defence-in-depth rather than a wire-protocol
476+
# mirror.
477+
assert _MAX_COLUMN_COUNT == 2000
475478

476479
def test_max_file_count_is_one_hundred(self) -> None:
477480
from dqlitewire.messages.responses import _MAX_FILE_COUNT

tests/test_max_column_count_cap.py

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
"""``_MAX_COLUMN_COUNT`` is set to dqlite's actual emission ceiling
2-
(255) so a hostile peer cannot inflate intermediate Python-side
3-
allocations by claiming a higher column count than the C server
4-
ever produces.
1+
"""``_MAX_COLUMN_COUNT`` is set to SQLite's documented column limit
2+
(``SQLITE_MAX_COLUMN = 2000``) so legitimate wide-table SELECT
3+
results decode while still rejecting absurd peer emissions.
54
6-
The C server's ``stmt.c:10`` defines
7-
``STMT__MAX_COLUMNS = (1 << 8) - 1 = 255``.
5+
The C server emits ``sqlite3_column_count(stmt)`` as a uint64
6+
without cap (``query.c:111-120``); ``stmt.c:10``'s
7+
``STMT__MAX_COLUMNS = (1 << 8) - 1 = 255`` macro is defined but
8+
never referenced. SQLite's compile-time default is 2000 (raisable
9+
to 32767 via ``SQLITE_MAX_COLUMN`` build flag); a wide-table
10+
SELECT against an analytics / feature-store schema legitimately
11+
crosses 255 columns.
12+
13+
The per-name cap (``_MAX_COLUMN_NAME_SIZE = 4096``) and the frame-
14+
envelope cap (default 64 MiB) already bound memory growth from the
15+
N × name allocation; this cap is defence-in-depth against
16+
pathological peer emissions, not the load-bearing memory bound.
817
"""
918

1019
import pytest
@@ -18,33 +27,51 @@
1827
from dqlitewire.types import encode_uint64
1928

2029

21-
def test_max_column_count_pinned_to_255() -> None:
22-
assert _MAX_COLUMN_COUNT == 255
30+
def test_max_column_count_pinned_to_sqlite_default() -> None:
31+
"""SQLite's documented default ``SQLITE_MAX_COLUMN`` is 2000."""
32+
assert _MAX_COLUMN_COUNT == 2000
2333

2434

25-
def test_rows_response_rejects_count_above_255() -> None:
26-
body = encode_uint64(256)
35+
def test_rows_response_rejects_count_above_cap() -> None:
36+
body = encode_uint64(_MAX_COLUMN_COUNT + 1)
2737
with pytest.raises(DecodeError, match="(?i)column count"):
2838
RowsResponse.decode_body(body)
2939

3040

31-
def test_rows_response_accepts_count_at_255() -> None:
32-
"""A 255-column rows response is well-formed and must not be
41+
def test_rows_response_accepts_count_at_cap() -> None:
42+
"""A 2000-column rows response is well-formed and must not be
3343
rejected by the cap; it fails the body-size check instead
3444
because we only sent the count, not the column names."""
35-
body = encode_uint64(255)
45+
body = encode_uint64(_MAX_COLUMN_COUNT)
46+
with pytest.raises(DecodeError, match="exceeds maximum possible"):
47+
RowsResponse.decode_body(body)
48+
49+
50+
def test_rows_response_accepts_count_above_old_255_cap() -> None:
51+
"""Pin the regression-vs-old-cap shape: a 1500-column emission
52+
(legitimate wide table, above the prior 255 cap but below the
53+
new 2000 cap) must NOT trip the column-count cap. It still
54+
fails the body-size check below because we only sent the count,
55+
not the per-column name payload."""
56+
body = encode_uint64(1500)
3657
with pytest.raises(DecodeError, match="exceeds maximum possible"):
3758
RowsResponse.decode_body(body)
3859

3960

40-
def test_servers_response_rejects_count_above_255() -> None:
41-
"""``ServersResponse`` shares the same column-count cap path
42-
via ``_MAX_NODE_COUNT`` (separate cap) but uses
43-
``_MAX_COLUMN_COUNT``-style protection on its own count field."""
44-
# ``ServersResponse`` uses ``_MAX_NODE_COUNT = 10_000``; the
45-
# column cap does not apply directly. Pinning here is a sanity
46-
# check that the cap constant was not accidentally inlined into
47-
# an unrelated field.
61+
def test_rows_response_rejects_absurd_count() -> None:
62+
"""A pathological emission (``column_count = 2^31``) must still
63+
be rejected so a hostile peer cannot inflate Python-side
64+
allocations."""
65+
body = encode_uint64(1 << 31)
66+
with pytest.raises(DecodeError, match="(?i)column count"):
67+
RowsResponse.decode_body(body)
68+
69+
70+
def test_servers_response_uses_separate_cap() -> None:
71+
"""``ServersResponse`` uses ``_MAX_NODE_COUNT = 10_000``; the
72+
column cap does not apply. Pinning here is a sanity check that
73+
the cap constant was not accidentally inlined into an unrelated
74+
field."""
4875
assert _MAX_COLUMN_COUNT < 10_000
4976

5077

0 commit comments

Comments
 (0)