Skip to content

Commit 94f1295

Browse files
Range-validate URL query parameters at parse time in the dqlite dialect
create_connect_args coerced each URL query value through a type converter but did not catch semantic out-of-range values: ?timeout=0, ?timeout=nan, ?timeout=-5, ?max_total_rows=-1, ?max_continuation_frames=0 all sailed through URL parsing and only surfaced as a ValueError deep inside the DBAPI constructor. At that point the error origin is a nested coroutine, not the URL the user actually wrote. Extend the allowed-keys table to (converter, validator) tuples and run the validator after coercion; an out-of-range value raises ArgumentError at parse time with a message that points straight at the offending URL parameter. The downstream DBAPI validation stays in place as a second layer. Parametric regression tests cover every numeric knob and accepted / rejected ranges. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1f46187 commit 94f1295

2 files changed

Lines changed: 60 additions & 10 deletions

File tree

src/sqlalchemydqlite/base.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import datetime
5+
import math
56
import warnings
67
from collections.abc import Callable
78
from typing import Any
@@ -126,23 +127,32 @@ def import_dbapi(cls) -> Any:
126127

127128
# Whitelist of URL query parameters we forward to the DBAPI connect
128129
# call. Unknown keys raise ``ArgumentError`` so typos surface.
130+
# Each entry pairs a string-to-value converter with an optional
131+
# predicate that runs after coercion to catch semantic out-of-range
132+
# values (zero, negative, NaN, inf). The predicate may be ``None`` for
133+
# bool knobs that don't admit a range check.
129134
# ``trust_server_heartbeat`` uses a URL-friendly bool parser because
130135
# bool("False") evaluates truthy (non-empty string).
131-
_URL_QUERY_ALLOWED: dict[str, Callable[[str], Any]] = {
132-
"timeout": float,
133-
"max_total_rows": int,
134-
"max_continuation_frames": int,
135-
"trust_server_heartbeat": lambda s: s.strip().lower() in ("1", "true", "yes", "on"),
136+
_URL_QUERY_ALLOWED: dict[str, tuple[Callable[[str], Any], Callable[[Any], bool] | None]] = {
137+
"timeout": (float, lambda v: math.isfinite(v) and v > 0),
138+
"max_total_rows": (int, lambda v: v > 0),
139+
"max_continuation_frames": (int, lambda v: v > 0),
140+
"trust_server_heartbeat": (
141+
lambda s: s.strip().lower() in ("1", "true", "yes", "on"),
142+
None,
143+
),
136144
}
137145

138146
def create_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
139147
"""Create connection arguments from URL.
140148
141149
URL format: ``dqlite://host:port/database?timeout=...``
142150
143-
Known query parameters are typed and passed through to the
144-
DBAPI. Unknown ones raise :class:`ArgumentError` so typos like
145-
``?timeoutt=5`` don't silently disappear.
151+
Known query parameters are typed and range-validated at URL-parse
152+
time so typos (``?timeoutt=5``), unparseable types
153+
(``?timeout=abc``), and out-of-range values
154+
(``?max_total_rows=-1``) all raise :class:`ArgumentError` before
155+
any pool is built.
146156
"""
147157
host = url.host or "localhost"
148158
port = url.port or 9001
@@ -158,17 +168,20 @@ def create_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
158168
f"Unknown dqlite URL query parameter {key!r}. "
159169
f"Allowed: {sorted(self._URL_QUERY_ALLOWED)}"
160170
)
161-
converter = self._URL_QUERY_ALLOWED[key]
171+
converter, validator = self._URL_QUERY_ALLOWED[key]
162172
# URL query values can be str or tuple[str, ...] (when a key
163173
# appears multiple times). Take the last occurrence.
164174
raw_str = raw[-1] if isinstance(raw, tuple) else raw
165175
try:
166-
kwargs[key] = converter(raw_str)
176+
value = converter(raw_str)
167177
except (TypeError, ValueError) as e:
168178
raise ArgumentError(
169179
f"Cannot convert URL query {key}={raw!r} to "
170180
f"{getattr(converter, '__name__', 'expected type')}: {e}"
171181
) from e
182+
if validator is not None and not validator(value):
183+
raise ArgumentError(f"URL query {key}={raw_str!r} is out of range")
184+
kwargs[key] = value
172185

173186
return [], kwargs
174187

tests/test_dialect_dialect_config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,43 @@ def test_trust_server_heartbeat_parses_boolean(self, raw: str, expected: bool) -
127127
assert kwargs["trust_server_heartbeat"] is expected
128128

129129

130+
class TestURLQueryRangeValidation:
131+
@pytest.mark.parametrize(
132+
"url",
133+
[
134+
"dqlite://host:19001/db?max_total_rows=0",
135+
"dqlite://host:19001/db?max_total_rows=-1",
136+
"dqlite://host:19001/db?max_continuation_frames=0",
137+
"dqlite://host:19001/db?max_continuation_frames=-5",
138+
"dqlite://host:19001/db?timeout=0",
139+
"dqlite://host:19001/db?timeout=-1.5",
140+
"dqlite://host:19001/db?timeout=nan",
141+
"dqlite://host:19001/db?timeout=inf",
142+
"dqlite://host:19001/db?timeout=-inf",
143+
],
144+
)
145+
def test_invalid_range_rejected_at_parse_time(self, url: str) -> None:
146+
dialect = DqliteDialect()
147+
with pytest.raises(ArgumentError, match="out of range"):
148+
dialect.create_connect_args(make_url(url))
149+
150+
@pytest.mark.parametrize(
151+
"url",
152+
[
153+
"dqlite://host:19001/db?max_total_rows=1",
154+
"dqlite://host:19001/db?max_total_rows=10000000",
155+
"dqlite://host:19001/db?max_continuation_frames=1",
156+
"dqlite://host:19001/db?timeout=0.001",
157+
"dqlite://host:19001/db?timeout=3600",
158+
],
159+
)
160+
def test_valid_range_accepted(self, url: str) -> None:
161+
dialect = DqliteDialect()
162+
_, kwargs = dialect.create_connect_args(make_url(url))
163+
# Just smoke-test that parsing completed without error.
164+
assert kwargs
165+
166+
130167
class TestURLGovernorsReachAioDbapi:
131168
"""End-to-end test: every URL governor knob must be accepted by the
132169
async DBAPI's connect(). The unit-level create_connect_args tests only

0 commit comments

Comments
 (0)