qplotter 1.6.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1745 @@
|
|
|
1
|
+
"""Bounded primitive-only IPC for the trusted live-reader helper.
|
|
2
|
+
|
|
3
|
+
The multiprocessing bootstrap necessarily transfers Python ``Connection``
|
|
4
|
+
handles to the spawned child. After bootstrap, every application frame is
|
|
5
|
+
canonical UTF-8 JSON sent with ``send_bytes``/``recv_bytes``. This module is
|
|
6
|
+
the sole encoder/decoder for that wire format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Final, TypeAlias
|
|
18
|
+
|
|
19
|
+
from qplot.datahandling.file_identity import (
|
|
20
|
+
MAX_DATABASE_SIDECAR_IDENTITIES,
|
|
21
|
+
DatabaseFileIdentity,
|
|
22
|
+
DatabaseInstance,
|
|
23
|
+
)
|
|
24
|
+
from qplot.datahandling.trusted_live import (
|
|
25
|
+
TRUSTED_LIVE_MAX_BATCH_QUERIES,
|
|
26
|
+
TRUSTED_LIVE_MAX_CELLS_PER_REPLY,
|
|
27
|
+
TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES,
|
|
28
|
+
TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT,
|
|
29
|
+
TRUSTED_LIVE_MAX_REPLY_BYTES,
|
|
30
|
+
TRUSTED_LIVE_MAX_ROWS_PER_RESULT,
|
|
31
|
+
TRUSTED_LIVE_MAX_SCALAR_BYTES,
|
|
32
|
+
SqliteBindings,
|
|
33
|
+
TrustedLiveReaderError,
|
|
34
|
+
TrustedLiveSourceIdentity,
|
|
35
|
+
TrustedQuery,
|
|
36
|
+
TrustedQueryResult,
|
|
37
|
+
preflight_trusted_query_results,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
PROTOCOL_VERSION: Final = 1
|
|
41
|
+
MAX_REQUEST_BYTES: Final = 1 * 1024 * 1024
|
|
42
|
+
MAX_CONTROL_BYTES: Final = 4 * 1024
|
|
43
|
+
MAX_REPLY_BYTES: Final = TRUSTED_LIVE_MAX_REPLY_BYTES
|
|
44
|
+
MAX_SQL_BYTES: Final = 256 * 1024
|
|
45
|
+
MAX_BATCH_QUERIES: Final = TRUSTED_LIVE_MAX_BATCH_QUERIES
|
|
46
|
+
MAX_BINDINGS_PER_QUERY: Final = 4_096
|
|
47
|
+
MAX_BINDING_NAME_BYTES: Final = TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES
|
|
48
|
+
MAX_COLUMNS_PER_RESULT: Final = TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT
|
|
49
|
+
MAX_ROWS_PER_RESULT: Final = TRUSTED_LIVE_MAX_ROWS_PER_RESULT
|
|
50
|
+
MAX_CELLS_PER_REPLY: Final = TRUSTED_LIVE_MAX_CELLS_PER_REPLY
|
|
51
|
+
MAX_SCALAR_BYTES: Final = TRUSTED_LIVE_MAX_SCALAR_BYTES
|
|
52
|
+
MAX_ERROR_MESSAGE_BYTES: Final = 16 * 1024
|
|
53
|
+
MAX_PATH_BYTES: Final = 32 * 1024
|
|
54
|
+
MAX_JSON_NESTING: Final = 12
|
|
55
|
+
MAX_JSON_COLLECTION_ITEMS: Final = 4_500_000
|
|
56
|
+
MAX_GENERATION: Final = (1 << 63) - 1
|
|
57
|
+
SESSION_HEX_CHARS: Final = 32
|
|
58
|
+
MIN_OPERATION_TIMEOUT_MS: Final = 0
|
|
59
|
+
MAX_OPERATION_TIMEOUT_MS: Final = 300_000
|
|
60
|
+
|
|
61
|
+
_INTEGER_MIN = -(1 << 63)
|
|
62
|
+
_INTEGER_MAX = (1 << 63) - 1
|
|
63
|
+
_IDENTITY_INTEGER_MAX = (1 << 64) - 1
|
|
64
|
+
_MAX_REAL_HEX_CHARS = 32
|
|
65
|
+
_MAX_BASE64_SCALAR_CHARS = 4 * ((MAX_SCALAR_BYTES + 2) // 3)
|
|
66
|
+
_MAX_BUSY_TIMEOUT_MS = (1 << 31) - 1
|
|
67
|
+
_MIN_STARTUP_TIMEOUT_MS = 1
|
|
68
|
+
_MIN_ORPHAN_GRACE_MS = 1
|
|
69
|
+
_FRAME_ENVELOPE_RESERVE_BYTES = 512
|
|
70
|
+
_WIRE_CONTAINER_BUDGET_BYTES = 32
|
|
71
|
+
_WIRE_FIELD_BUDGET_BYTES = 16
|
|
72
|
+
_LOWERCASE_HEX_DIGITS = frozenset("0123456789abcdef")
|
|
73
|
+
_REQUEST_OPERATIONS = frozenset(
|
|
74
|
+
{"startup", "query", "query_batch", "data_version", "shutdown"}
|
|
75
|
+
)
|
|
76
|
+
_REPLY_OPERATIONS = _REQUEST_OPERATIONS | {"startup", "protocol"}
|
|
77
|
+
_CONTROL_OPERATIONS = frozenset({"cancel"})
|
|
78
|
+
_ERROR_CODES = frozenset(
|
|
79
|
+
{
|
|
80
|
+
"reader_unavailable",
|
|
81
|
+
"unsupported_source",
|
|
82
|
+
"source_changed",
|
|
83
|
+
"source_io",
|
|
84
|
+
"sql_rejected",
|
|
85
|
+
"query_failed",
|
|
86
|
+
"result_limit",
|
|
87
|
+
"busy_timeout",
|
|
88
|
+
"cancelled",
|
|
89
|
+
"operation_deadline",
|
|
90
|
+
"invalid_database",
|
|
91
|
+
"cleanup_quarantine",
|
|
92
|
+
"reader_closed",
|
|
93
|
+
"reader_thread",
|
|
94
|
+
"transaction",
|
|
95
|
+
"reader_error",
|
|
96
|
+
"protocol_error",
|
|
97
|
+
"internal_error",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
JsonValue: TypeAlias = (
|
|
102
|
+
None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TrustedLiveProtocolValidationError(ValueError):
|
|
107
|
+
"""Raised when an IPC frame or payload fails closed validation."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class ProtocolEnvelope:
|
|
112
|
+
"""One validated protocol envelope with an operation-specific payload."""
|
|
113
|
+
|
|
114
|
+
session: str
|
|
115
|
+
generation: int
|
|
116
|
+
operation: str
|
|
117
|
+
payload: dict[str, Any]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True)
|
|
121
|
+
class _WireSizeBudget:
|
|
122
|
+
"""Conservatively bound aggregate canonical-wire construction."""
|
|
123
|
+
|
|
124
|
+
maximum: int
|
|
125
|
+
used: int = 0
|
|
126
|
+
|
|
127
|
+
def consume(self, amount: int, description: str) -> None:
|
|
128
|
+
if amount < 0 or amount > self.maximum - self.used:
|
|
129
|
+
raise TrustedLiveProtocolValidationError(
|
|
130
|
+
f"{description} exceeds the aggregate {self.maximum}-byte "
|
|
131
|
+
"protocol wire budget."
|
|
132
|
+
)
|
|
133
|
+
self.used += amount
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _new_role_budget(maximum: int) -> _WireSizeBudget:
|
|
137
|
+
budget = _WireSizeBudget(maximum)
|
|
138
|
+
budget.consume(_FRAME_ENVELOPE_RESERVE_BYTES, "The protocol envelope")
|
|
139
|
+
return budget
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
143
|
+
result: dict[str, Any] = {}
|
|
144
|
+
for key, value in pairs:
|
|
145
|
+
if key in result:
|
|
146
|
+
raise TrustedLiveProtocolValidationError(
|
|
147
|
+
f"Duplicate JSON object key {key!r} was rejected."
|
|
148
|
+
)
|
|
149
|
+
result[key] = value
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _text_sizes(value: str, description: str, maximum: int) -> tuple[int, int]:
|
|
154
|
+
"""Return UTF-8 and canonical JSON-string sizes without allocating either."""
|
|
155
|
+
|
|
156
|
+
utf8_size = 0
|
|
157
|
+
json_size = 2 # Opening and closing quotes.
|
|
158
|
+
for character in value:
|
|
159
|
+
codepoint = ord(character)
|
|
160
|
+
if 0xD800 <= codepoint <= 0xDFFF:
|
|
161
|
+
raise TrustedLiveProtocolValidationError(
|
|
162
|
+
f"{description} must contain valid Unicode text."
|
|
163
|
+
)
|
|
164
|
+
if codepoint <= 0x7F:
|
|
165
|
+
character_bytes = 1
|
|
166
|
+
elif codepoint <= 0x7FF:
|
|
167
|
+
character_bytes = 2
|
|
168
|
+
elif codepoint <= 0xFFFF:
|
|
169
|
+
character_bytes = 3
|
|
170
|
+
else:
|
|
171
|
+
character_bytes = 4
|
|
172
|
+
utf8_size += character_bytes
|
|
173
|
+
if utf8_size > maximum:
|
|
174
|
+
raise TrustedLiveProtocolValidationError(
|
|
175
|
+
f"{description} exceeds the {maximum}-byte protocol limit."
|
|
176
|
+
)
|
|
177
|
+
if character in {'"', "\\"}:
|
|
178
|
+
json_size += 2
|
|
179
|
+
elif codepoint <= 0x1F:
|
|
180
|
+
json_size += 2 if character in "\b\t\n\f\r" else 6
|
|
181
|
+
else:
|
|
182
|
+
json_size += character_bytes
|
|
183
|
+
return utf8_size, json_size
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _utf8_size(value: str, description: str, maximum: int) -> int:
|
|
187
|
+
return _text_sizes(value, description, maximum)[0]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _json_string_wire_size(value: str, description: str, maximum: int) -> int:
|
|
191
|
+
return _text_sizes(value, description, maximum)[1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_session(session: object) -> str:
|
|
195
|
+
if (
|
|
196
|
+
type(session) is not str
|
|
197
|
+
or len(session) != SESSION_HEX_CHARS
|
|
198
|
+
or any(character not in _LOWERCASE_HEX_DIGITS for character in session)
|
|
199
|
+
):
|
|
200
|
+
raise TrustedLiveProtocolValidationError(
|
|
201
|
+
"The protocol session must be exactly 32 lowercase hexadecimal characters."
|
|
202
|
+
)
|
|
203
|
+
return session
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_generation(generation: object, *, allow_zero: bool) -> int:
|
|
207
|
+
minimum = 0 if allow_zero else 1
|
|
208
|
+
if (
|
|
209
|
+
type(generation) is not int
|
|
210
|
+
or generation < minimum
|
|
211
|
+
or generation > MAX_GENERATION
|
|
212
|
+
):
|
|
213
|
+
raise TrustedLiveProtocolValidationError(
|
|
214
|
+
f"The protocol generation must be an integer from {minimum} "
|
|
215
|
+
f"through {MAX_GENERATION}."
|
|
216
|
+
)
|
|
217
|
+
return generation
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _preflight_json_value(root: object, budget: _WireSizeBudget) -> None:
|
|
221
|
+
"""Bound a generic primitive JSON tree before ``json.dumps`` allocates."""
|
|
222
|
+
|
|
223
|
+
active_collections: set[int] = set()
|
|
224
|
+
collection_items = 0
|
|
225
|
+
|
|
226
|
+
def visit(value: object, depth: int) -> None:
|
|
227
|
+
nonlocal collection_items
|
|
228
|
+
if depth > MAX_JSON_NESTING:
|
|
229
|
+
raise TrustedLiveProtocolValidationError(
|
|
230
|
+
"The protocol value exceeds the JSON nesting limit."
|
|
231
|
+
)
|
|
232
|
+
if isinstance(value, Mapping):
|
|
233
|
+
identity = id(value)
|
|
234
|
+
if identity in active_collections:
|
|
235
|
+
raise TrustedLiveProtocolValidationError(
|
|
236
|
+
"The protocol value contains a circular collection."
|
|
237
|
+
)
|
|
238
|
+
collection_items += len(value)
|
|
239
|
+
if collection_items > MAX_JSON_COLLECTION_ITEMS:
|
|
240
|
+
raise TrustedLiveProtocolValidationError(
|
|
241
|
+
"The protocol value contains too many collection items."
|
|
242
|
+
)
|
|
243
|
+
# Count exact JSON punctuation here. Operation-specific encoders
|
|
244
|
+
# have already applied their own conservative resource budgets;
|
|
245
|
+
# charging another arbitrary per-container reserve would reject
|
|
246
|
+
# live results that are safely within both that budget and the
|
|
247
|
+
# exact frame cap.
|
|
248
|
+
budget.consume(
|
|
249
|
+
2 + len(value) + max(0, len(value) - 1),
|
|
250
|
+
"The protocol frame",
|
|
251
|
+
)
|
|
252
|
+
active_collections.add(identity)
|
|
253
|
+
try:
|
|
254
|
+
for key, child in value.items():
|
|
255
|
+
if type(key) is not str:
|
|
256
|
+
raise TrustedLiveProtocolValidationError(
|
|
257
|
+
"A protocol object key is not text."
|
|
258
|
+
)
|
|
259
|
+
budget.consume(
|
|
260
|
+
_json_string_wire_size(
|
|
261
|
+
key,
|
|
262
|
+
"A protocol object key",
|
|
263
|
+
budget.maximum,
|
|
264
|
+
),
|
|
265
|
+
"The protocol frame",
|
|
266
|
+
)
|
|
267
|
+
visit(child, depth + 1)
|
|
268
|
+
finally:
|
|
269
|
+
active_collections.remove(identity)
|
|
270
|
+
return
|
|
271
|
+
if isinstance(value, (list, tuple)):
|
|
272
|
+
identity = id(value)
|
|
273
|
+
if identity in active_collections:
|
|
274
|
+
raise TrustedLiveProtocolValidationError(
|
|
275
|
+
"The protocol value contains a circular collection."
|
|
276
|
+
)
|
|
277
|
+
collection_items += len(value)
|
|
278
|
+
if collection_items > MAX_JSON_COLLECTION_ITEMS:
|
|
279
|
+
raise TrustedLiveProtocolValidationError(
|
|
280
|
+
"The protocol value contains too many collection items."
|
|
281
|
+
)
|
|
282
|
+
budget.consume(2 + max(0, len(value) - 1), "The protocol frame")
|
|
283
|
+
active_collections.add(identity)
|
|
284
|
+
try:
|
|
285
|
+
for child in value:
|
|
286
|
+
visit(child, depth + 1)
|
|
287
|
+
finally:
|
|
288
|
+
active_collections.remove(identity)
|
|
289
|
+
return
|
|
290
|
+
if value is None:
|
|
291
|
+
budget.consume(4, "The protocol frame")
|
|
292
|
+
return
|
|
293
|
+
if isinstance(value, bool):
|
|
294
|
+
budget.consume(5, "The protocol frame")
|
|
295
|
+
return
|
|
296
|
+
if type(value) is int:
|
|
297
|
+
try:
|
|
298
|
+
integer_size = len(str(value))
|
|
299
|
+
except ValueError as error:
|
|
300
|
+
raise TrustedLiveProtocolValidationError(
|
|
301
|
+
"A protocol integer is too large to encode."
|
|
302
|
+
) from error
|
|
303
|
+
budget.consume(integer_size, "The protocol frame")
|
|
304
|
+
return
|
|
305
|
+
if isinstance(value, float):
|
|
306
|
+
budget.consume(32, "The protocol frame")
|
|
307
|
+
return
|
|
308
|
+
if isinstance(value, str):
|
|
309
|
+
budget.consume(
|
|
310
|
+
_json_string_wire_size(
|
|
311
|
+
value,
|
|
312
|
+
"A protocol text value",
|
|
313
|
+
budget.maximum,
|
|
314
|
+
),
|
|
315
|
+
"The protocol frame",
|
|
316
|
+
)
|
|
317
|
+
return
|
|
318
|
+
raise TrustedLiveProtocolValidationError(
|
|
319
|
+
"The protocol payload is not canonical JSON primitive data."
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# The eventual envelope object occupies nesting level one.
|
|
323
|
+
visit(root, 2)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def encode_frame(
|
|
327
|
+
session: str,
|
|
328
|
+
generation: int,
|
|
329
|
+
operation: str,
|
|
330
|
+
payload: Mapping[str, Any],
|
|
331
|
+
*,
|
|
332
|
+
maximum_bytes: int,
|
|
333
|
+
) -> bytes:
|
|
334
|
+
"""Encode one exact envelope and enforce its role-specific frame cap."""
|
|
335
|
+
|
|
336
|
+
validate_session(session)
|
|
337
|
+
validate_generation(generation, allow_zero=True)
|
|
338
|
+
if not isinstance(operation, str) or not operation:
|
|
339
|
+
raise TrustedLiveProtocolValidationError(
|
|
340
|
+
"The protocol operation must be non-empty text."
|
|
341
|
+
)
|
|
342
|
+
if not isinstance(payload, Mapping):
|
|
343
|
+
raise TrustedLiveProtocolValidationError(
|
|
344
|
+
"The protocol payload must be an object."
|
|
345
|
+
)
|
|
346
|
+
budget = _new_role_budget(maximum_bytes)
|
|
347
|
+
budget.consume(
|
|
348
|
+
_json_string_wire_size(
|
|
349
|
+
operation,
|
|
350
|
+
"The protocol operation",
|
|
351
|
+
maximum_bytes,
|
|
352
|
+
),
|
|
353
|
+
"The protocol envelope",
|
|
354
|
+
)
|
|
355
|
+
_preflight_json_value(payload, budget)
|
|
356
|
+
envelope = {
|
|
357
|
+
"generation": generation,
|
|
358
|
+
"operation": operation,
|
|
359
|
+
"payload": dict(payload),
|
|
360
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
361
|
+
"session": session,
|
|
362
|
+
}
|
|
363
|
+
try:
|
|
364
|
+
encoded = json.dumps(
|
|
365
|
+
envelope,
|
|
366
|
+
allow_nan=False,
|
|
367
|
+
ensure_ascii=False,
|
|
368
|
+
separators=(",", ":"),
|
|
369
|
+
sort_keys=True,
|
|
370
|
+
).encode("utf-8", errors="strict")
|
|
371
|
+
except (TypeError, ValueError, UnicodeEncodeError) as error:
|
|
372
|
+
raise TrustedLiveProtocolValidationError(
|
|
373
|
+
"The protocol payload is not canonical JSON primitive data."
|
|
374
|
+
) from error
|
|
375
|
+
if not encoded or len(encoded) > maximum_bytes:
|
|
376
|
+
raise TrustedLiveProtocolValidationError(
|
|
377
|
+
f"The encoded protocol frame exceeds the {maximum_bytes}-byte limit."
|
|
378
|
+
)
|
|
379
|
+
return encoded
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def decode_frame(
|
|
383
|
+
frame: bytes,
|
|
384
|
+
*,
|
|
385
|
+
maximum_bytes: int,
|
|
386
|
+
allowed_operations: frozenset[str],
|
|
387
|
+
) -> ProtocolEnvelope:
|
|
388
|
+
"""Decode and validate the common envelope without trusting pickle."""
|
|
389
|
+
|
|
390
|
+
if type(frame) is not bytes or not frame or len(frame) > maximum_bytes:
|
|
391
|
+
raise TrustedLiveProtocolValidationError(
|
|
392
|
+
f"The protocol frame must contain 1 through {maximum_bytes} bytes."
|
|
393
|
+
)
|
|
394
|
+
try:
|
|
395
|
+
decoded = json.loads(
|
|
396
|
+
frame.decode("utf-8", errors="strict"),
|
|
397
|
+
object_pairs_hook=_reject_duplicate_keys,
|
|
398
|
+
parse_constant=lambda value: _raise_invalid_constant(value),
|
|
399
|
+
parse_float=_parse_finite_json_float,
|
|
400
|
+
)
|
|
401
|
+
except TrustedLiveProtocolValidationError:
|
|
402
|
+
raise
|
|
403
|
+
except (UnicodeDecodeError, ValueError, RecursionError) as error:
|
|
404
|
+
raise TrustedLiveProtocolValidationError(
|
|
405
|
+
"The protocol frame is not valid bounded UTF-8 JSON."
|
|
406
|
+
) from error
|
|
407
|
+
try:
|
|
408
|
+
_validate_json_tree(decoded)
|
|
409
|
+
except RecursionError as error:
|
|
410
|
+
raise TrustedLiveProtocolValidationError(
|
|
411
|
+
"The protocol frame exceeds the JSON nesting limit."
|
|
412
|
+
) from error
|
|
413
|
+
if not isinstance(decoded, dict) or set(decoded) != {
|
|
414
|
+
"protocol_version",
|
|
415
|
+
"session",
|
|
416
|
+
"generation",
|
|
417
|
+
"operation",
|
|
418
|
+
"payload",
|
|
419
|
+
}:
|
|
420
|
+
raise TrustedLiveProtocolValidationError(
|
|
421
|
+
"The protocol envelope has missing, extra, or invalid fields."
|
|
422
|
+
)
|
|
423
|
+
if type(decoded["protocol_version"]) is not int or (
|
|
424
|
+
decoded["protocol_version"] != PROTOCOL_VERSION
|
|
425
|
+
):
|
|
426
|
+
raise TrustedLiveProtocolValidationError("The protocol version is unsupported.")
|
|
427
|
+
session = validate_session(decoded["session"])
|
|
428
|
+
generation = validate_generation(decoded["generation"], allow_zero=True)
|
|
429
|
+
operation = decoded["operation"]
|
|
430
|
+
if not isinstance(operation, str) or operation not in allowed_operations:
|
|
431
|
+
raise TrustedLiveProtocolValidationError(
|
|
432
|
+
"The protocol operation is unknown for this channel."
|
|
433
|
+
)
|
|
434
|
+
payload = decoded["payload"]
|
|
435
|
+
if not isinstance(payload, dict):
|
|
436
|
+
raise TrustedLiveProtocolValidationError(
|
|
437
|
+
"The protocol payload must be an object."
|
|
438
|
+
)
|
|
439
|
+
return ProtocolEnvelope(session, generation, operation, payload)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _validate_json_tree(root: object) -> None:
|
|
443
|
+
"""Bound nesting and collection fan-out before shape-specific use."""
|
|
444
|
+
|
|
445
|
+
collection_items = 0
|
|
446
|
+
|
|
447
|
+
def visit(value: object, depth: int) -> None:
|
|
448
|
+
nonlocal collection_items
|
|
449
|
+
if isinstance(value, dict):
|
|
450
|
+
if depth > MAX_JSON_NESTING:
|
|
451
|
+
raise TrustedLiveProtocolValidationError(
|
|
452
|
+
"The protocol frame exceeds the JSON nesting limit."
|
|
453
|
+
)
|
|
454
|
+
collection_items += len(value)
|
|
455
|
+
if collection_items > MAX_JSON_COLLECTION_ITEMS:
|
|
456
|
+
raise TrustedLiveProtocolValidationError(
|
|
457
|
+
"The protocol frame contains too many collection items."
|
|
458
|
+
)
|
|
459
|
+
for key, child in value.items():
|
|
460
|
+
if not isinstance(key, str):
|
|
461
|
+
raise TrustedLiveProtocolValidationError(
|
|
462
|
+
"A protocol object key is not text."
|
|
463
|
+
)
|
|
464
|
+
visit(child, depth + 1)
|
|
465
|
+
elif isinstance(value, list):
|
|
466
|
+
if depth > MAX_JSON_NESTING:
|
|
467
|
+
raise TrustedLiveProtocolValidationError(
|
|
468
|
+
"The protocol frame exceeds the JSON nesting limit."
|
|
469
|
+
)
|
|
470
|
+
collection_items += len(value)
|
|
471
|
+
if collection_items > MAX_JSON_COLLECTION_ITEMS:
|
|
472
|
+
raise TrustedLiveProtocolValidationError(
|
|
473
|
+
"The protocol frame contains too many collection items."
|
|
474
|
+
)
|
|
475
|
+
for child in value:
|
|
476
|
+
visit(child, depth + 1)
|
|
477
|
+
elif isinstance(value, float):
|
|
478
|
+
if not math.isfinite(value):
|
|
479
|
+
raise TrustedLiveProtocolValidationError(
|
|
480
|
+
"The protocol contains a non-finite JSON number."
|
|
481
|
+
)
|
|
482
|
+
elif value is not None and not isinstance(value, (bool, int, str)):
|
|
483
|
+
raise TrustedLiveProtocolValidationError(
|
|
484
|
+
"The protocol contains a non-primitive JSON value."
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
visit(root, 1)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _raise_invalid_constant(value: str) -> None:
|
|
491
|
+
raise TrustedLiveProtocolValidationError(
|
|
492
|
+
f"The non-finite JSON constant {value!r} was rejected."
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _parse_finite_json_float(value: str) -> float:
|
|
497
|
+
decoded = float(value)
|
|
498
|
+
if not math.isfinite(decoded):
|
|
499
|
+
raise TrustedLiveProtocolValidationError(
|
|
500
|
+
f"The non-finite JSON number {value!r} was rejected."
|
|
501
|
+
)
|
|
502
|
+
return decoded
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def decode_request_frame(frame: bytes) -> ProtocolEnvelope:
|
|
506
|
+
return decode_frame(
|
|
507
|
+
frame,
|
|
508
|
+
maximum_bytes=MAX_REQUEST_BYTES,
|
|
509
|
+
allowed_operations=_REQUEST_OPERATIONS,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def decode_control_frame(frame: bytes) -> ProtocolEnvelope:
|
|
514
|
+
return decode_frame(
|
|
515
|
+
frame,
|
|
516
|
+
maximum_bytes=MAX_CONTROL_BYTES,
|
|
517
|
+
allowed_operations=_CONTROL_OPERATIONS,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def decode_reply_frame(frame: bytes) -> ProtocolEnvelope:
|
|
522
|
+
return decode_frame(
|
|
523
|
+
frame,
|
|
524
|
+
maximum_bytes=MAX_REPLY_BYTES,
|
|
525
|
+
allowed_operations=_REPLY_OPERATIONS,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _scalar_wire_size(value: object) -> int:
|
|
530
|
+
"""Validate one scalar and conservatively size its tagged JSON form."""
|
|
531
|
+
|
|
532
|
+
if value is None:
|
|
533
|
+
return 8
|
|
534
|
+
if isinstance(value, bool):
|
|
535
|
+
return 16
|
|
536
|
+
if isinstance(value, int):
|
|
537
|
+
if value < _INTEGER_MIN or value > _INTEGER_MAX:
|
|
538
|
+
raise TrustedLiveProtocolValidationError(
|
|
539
|
+
"SQLite integer values must fit in a signed 64-bit integer."
|
|
540
|
+
)
|
|
541
|
+
return 16 + len(str(value))
|
|
542
|
+
if isinstance(value, float):
|
|
543
|
+
return 16 + len(value.hex())
|
|
544
|
+
if isinstance(value, str):
|
|
545
|
+
return 16 + _json_string_wire_size(
|
|
546
|
+
value,
|
|
547
|
+
"A SQLite text value",
|
|
548
|
+
MAX_SCALAR_BYTES,
|
|
549
|
+
)
|
|
550
|
+
if isinstance(value, memoryview):
|
|
551
|
+
blob_size = value.nbytes
|
|
552
|
+
elif isinstance(value, (bytes, bytearray)):
|
|
553
|
+
blob_size = len(value)
|
|
554
|
+
else:
|
|
555
|
+
raise TrustedLiveProtocolValidationError(
|
|
556
|
+
"Only SQLite null, integer, real, text, and blob scalars may cross IPC."
|
|
557
|
+
)
|
|
558
|
+
if blob_size > MAX_SCALAR_BYTES:
|
|
559
|
+
raise TrustedLiveProtocolValidationError(
|
|
560
|
+
f"A SQLite blob exceeds the {MAX_SCALAR_BYTES}-byte limit."
|
|
561
|
+
)
|
|
562
|
+
base64_size = 4 * ((blob_size + 2) // 3)
|
|
563
|
+
return 16 + base64_size
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _preflight_scalar(
|
|
567
|
+
value: object,
|
|
568
|
+
budget: _WireSizeBudget,
|
|
569
|
+
description: str,
|
|
570
|
+
) -> None:
|
|
571
|
+
budget.consume(_scalar_wire_size(value), description)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _encode_scalar(value: object) -> list[JsonValue]:
|
|
575
|
+
if value is None:
|
|
576
|
+
return ["null"]
|
|
577
|
+
if isinstance(value, bool):
|
|
578
|
+
return ["integer", int(value)]
|
|
579
|
+
if isinstance(value, int):
|
|
580
|
+
if value < _INTEGER_MIN or value > _INTEGER_MAX:
|
|
581
|
+
raise TrustedLiveProtocolValidationError(
|
|
582
|
+
"SQLite integer values must fit in a signed 64-bit integer."
|
|
583
|
+
)
|
|
584
|
+
return ["integer", value]
|
|
585
|
+
if isinstance(value, float):
|
|
586
|
+
return ["real", value.hex()]
|
|
587
|
+
if isinstance(value, str):
|
|
588
|
+
_utf8_size(value, "A SQLite text value", MAX_SCALAR_BYTES)
|
|
589
|
+
return ["text", value]
|
|
590
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
591
|
+
blob = bytes(value)
|
|
592
|
+
if len(blob) > MAX_SCALAR_BYTES:
|
|
593
|
+
raise TrustedLiveProtocolValidationError(
|
|
594
|
+
f"A SQLite blob exceeds the {MAX_SCALAR_BYTES}-byte limit."
|
|
595
|
+
)
|
|
596
|
+
return ["blob", base64.b64encode(blob).decode("ascii")]
|
|
597
|
+
raise TrustedLiveProtocolValidationError(
|
|
598
|
+
"Only SQLite null, integer, real, text, and blob scalars may cross IPC."
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _decode_scalar(encoded: object) -> None | int | float | str | bytes:
|
|
603
|
+
if not isinstance(encoded, list) or not encoded or not isinstance(encoded[0], str):
|
|
604
|
+
raise TrustedLiveProtocolValidationError(
|
|
605
|
+
"A SQLite scalar has an invalid tagged representation."
|
|
606
|
+
)
|
|
607
|
+
tag = encoded[0]
|
|
608
|
+
if tag == "null" and len(encoded) == 1:
|
|
609
|
+
return None
|
|
610
|
+
if tag == "integer" and len(encoded) == 2:
|
|
611
|
+
value = encoded[1]
|
|
612
|
+
if type(value) is int and _INTEGER_MIN <= value <= _INTEGER_MAX:
|
|
613
|
+
return value
|
|
614
|
+
elif (
|
|
615
|
+
tag == "real"
|
|
616
|
+
and len(encoded) == 2
|
|
617
|
+
and isinstance(encoded[1], str)
|
|
618
|
+
and len(encoded[1]) <= _MAX_REAL_HEX_CHARS
|
|
619
|
+
):
|
|
620
|
+
try:
|
|
621
|
+
value = float.fromhex(encoded[1])
|
|
622
|
+
except ValueError:
|
|
623
|
+
pass
|
|
624
|
+
else:
|
|
625
|
+
if value.hex() == encoded[1]:
|
|
626
|
+
return value
|
|
627
|
+
elif tag == "text" and len(encoded) == 2 and isinstance(encoded[1], str):
|
|
628
|
+
_utf8_size(encoded[1], "A SQLite text value", MAX_SCALAR_BYTES)
|
|
629
|
+
return encoded[1]
|
|
630
|
+
elif (
|
|
631
|
+
tag == "blob"
|
|
632
|
+
and len(encoded) == 2
|
|
633
|
+
and isinstance(encoded[1], str)
|
|
634
|
+
and len(encoded[1]) <= _MAX_BASE64_SCALAR_CHARS
|
|
635
|
+
):
|
|
636
|
+
try:
|
|
637
|
+
value = base64.b64decode(encoded[1], validate=True)
|
|
638
|
+
except (ValueError, binascii.Error):
|
|
639
|
+
pass
|
|
640
|
+
else:
|
|
641
|
+
if len(value) <= MAX_SCALAR_BYTES:
|
|
642
|
+
return value
|
|
643
|
+
raise TrustedLiveProtocolValidationError(
|
|
644
|
+
"A SQLite scalar has an invalid or oversized tagged value."
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _preflight_bindings(
|
|
649
|
+
bindings: SqliteBindings,
|
|
650
|
+
budget: _WireSizeBudget,
|
|
651
|
+
) -> None:
|
|
652
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query bindings")
|
|
653
|
+
if bindings is None:
|
|
654
|
+
return
|
|
655
|
+
if isinstance(bindings, Mapping):
|
|
656
|
+
if len(bindings) > MAX_BINDINGS_PER_QUERY:
|
|
657
|
+
raise TrustedLiveProtocolValidationError(
|
|
658
|
+
"A query has too many named bindings."
|
|
659
|
+
)
|
|
660
|
+
seen: set[str] = set()
|
|
661
|
+
for key, value in bindings.items():
|
|
662
|
+
if not isinstance(key, str) or not key or key in seen:
|
|
663
|
+
raise TrustedLiveProtocolValidationError(
|
|
664
|
+
"Named binding keys must be unique non-empty text."
|
|
665
|
+
)
|
|
666
|
+
key_size = _json_string_wire_size(
|
|
667
|
+
key,
|
|
668
|
+
"A named binding key",
|
|
669
|
+
MAX_BINDING_NAME_BYTES,
|
|
670
|
+
)
|
|
671
|
+
seen.add(key)
|
|
672
|
+
budget.consume(
|
|
673
|
+
_WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + key_size,
|
|
674
|
+
"Named query bindings",
|
|
675
|
+
)
|
|
676
|
+
_preflight_scalar(value, budget, "Query binding values")
|
|
677
|
+
return
|
|
678
|
+
if isinstance(bindings, (str, bytes, bytearray, memoryview)):
|
|
679
|
+
values: Sequence[object] = (bindings,)
|
|
680
|
+
elif isinstance(bindings, Sequence):
|
|
681
|
+
values = bindings
|
|
682
|
+
else:
|
|
683
|
+
raise TrustedLiveProtocolValidationError(
|
|
684
|
+
"SQLite bindings must be a sequence, mapping, scalar text/blob, or None."
|
|
685
|
+
)
|
|
686
|
+
if len(values) > MAX_BINDINGS_PER_QUERY:
|
|
687
|
+
raise TrustedLiveProtocolValidationError("A query has too many bindings.")
|
|
688
|
+
for value in values:
|
|
689
|
+
budget.consume(_WIRE_FIELD_BUDGET_BYTES, "Sequential query bindings")
|
|
690
|
+
_preflight_scalar(value, budget, "Query binding values")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _bounded_binding_references(bindings: SqliteBindings) -> SqliteBindings:
|
|
694
|
+
"""Materialise at most the binding-count cap without copying blob data."""
|
|
695
|
+
|
|
696
|
+
if bindings is None:
|
|
697
|
+
return None
|
|
698
|
+
if isinstance(bindings, Mapping):
|
|
699
|
+
try:
|
|
700
|
+
binding_count = len(bindings)
|
|
701
|
+
except Exception as error:
|
|
702
|
+
raise TrustedLiveProtocolValidationError(
|
|
703
|
+
"Named query bindings do not expose a finite size."
|
|
704
|
+
) from error
|
|
705
|
+
if binding_count > MAX_BINDINGS_PER_QUERY:
|
|
706
|
+
raise TrustedLiveProtocolValidationError(
|
|
707
|
+
"A query has too many named bindings."
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
iterator = iter(bindings.items())
|
|
712
|
+
except Exception as error:
|
|
713
|
+
raise TrustedLiveProtocolValidationError(
|
|
714
|
+
"Named query bindings cannot be inspected safely."
|
|
715
|
+
) from error
|
|
716
|
+
referenced: dict[str, Any] = {}
|
|
717
|
+
for _ in range(binding_count):
|
|
718
|
+
try:
|
|
719
|
+
item = next(iterator)
|
|
720
|
+
except StopIteration as error:
|
|
721
|
+
raise TrustedLiveProtocolValidationError(
|
|
722
|
+
"Named query bindings changed while being inspected."
|
|
723
|
+
) from error
|
|
724
|
+
except Exception as error:
|
|
725
|
+
raise TrustedLiveProtocolValidationError(
|
|
726
|
+
"Named query bindings cannot be inspected safely."
|
|
727
|
+
) from error
|
|
728
|
+
if not isinstance(item, tuple) or len(item) != 2:
|
|
729
|
+
raise TrustedLiveProtocolValidationError(
|
|
730
|
+
"Named query bindings contain a malformed item."
|
|
731
|
+
)
|
|
732
|
+
key, value = item
|
|
733
|
+
if not isinstance(key, str) or not key or key in referenced:
|
|
734
|
+
raise TrustedLiveProtocolValidationError(
|
|
735
|
+
"Named binding keys must be unique non-empty text."
|
|
736
|
+
)
|
|
737
|
+
referenced[key] = value
|
|
738
|
+
try:
|
|
739
|
+
next(iterator)
|
|
740
|
+
except StopIteration:
|
|
741
|
+
pass
|
|
742
|
+
except Exception as error:
|
|
743
|
+
raise TrustedLiveProtocolValidationError(
|
|
744
|
+
"Named query bindings cannot be inspected safely."
|
|
745
|
+
) from error
|
|
746
|
+
else:
|
|
747
|
+
raise TrustedLiveProtocolValidationError(
|
|
748
|
+
"Named query bindings changed while being inspected."
|
|
749
|
+
)
|
|
750
|
+
return referenced
|
|
751
|
+
elif isinstance(bindings, (str, bytes, bytearray, memoryview)):
|
|
752
|
+
# These are single SQLite scalars, never collections of characters or
|
|
753
|
+
# integer bytes. In particular, Stage 2 otherwise expands memoryview
|
|
754
|
+
# into a tuple of integers in TrustedQuery.__post_init__.
|
|
755
|
+
return (bindings,)
|
|
756
|
+
elif isinstance(bindings, Sequence):
|
|
757
|
+
try:
|
|
758
|
+
binding_count = len(bindings)
|
|
759
|
+
except Exception as error:
|
|
760
|
+
raise TrustedLiveProtocolValidationError(
|
|
761
|
+
"Sequential query bindings do not expose a finite size."
|
|
762
|
+
) from error
|
|
763
|
+
if binding_count > MAX_BINDINGS_PER_QUERY:
|
|
764
|
+
raise TrustedLiveProtocolValidationError("A query has too many bindings.")
|
|
765
|
+
referenced_values: list[Any] = []
|
|
766
|
+
for index in range(binding_count):
|
|
767
|
+
try:
|
|
768
|
+
referenced_values.append(bindings[index])
|
|
769
|
+
except Exception as error:
|
|
770
|
+
raise TrustedLiveProtocolValidationError(
|
|
771
|
+
"Sequential query bindings changed while being inspected."
|
|
772
|
+
) from error
|
|
773
|
+
return tuple(referenced_values)
|
|
774
|
+
raise TrustedLiveProtocolValidationError(
|
|
775
|
+
"SQLite bindings must be a sequence, mapping, scalar text/blob, or None."
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _freeze_binding_scalar(
|
|
780
|
+
value: Any,
|
|
781
|
+
budget: _WireSizeBudget,
|
|
782
|
+
) -> Any:
|
|
783
|
+
"""Charge and snapshot one scalar without an unbounded mutable-blob copy."""
|
|
784
|
+
|
|
785
|
+
if isinstance(value, bytearray):
|
|
786
|
+
view: memoryview | None = None
|
|
787
|
+
try:
|
|
788
|
+
# Exporting a view prevents another thread from resizing the
|
|
789
|
+
# bytearray between sizing and copying it.
|
|
790
|
+
view = memoryview(value)
|
|
791
|
+
_preflight_scalar(view, budget, "Query binding values")
|
|
792
|
+
return view.tobytes()
|
|
793
|
+
except TrustedLiveProtocolValidationError:
|
|
794
|
+
raise
|
|
795
|
+
except (BufferError, TypeError, ValueError) as error:
|
|
796
|
+
raise TrustedLiveProtocolValidationError(
|
|
797
|
+
"A mutable SQLite blob binding could not be snapshotted safely."
|
|
798
|
+
) from error
|
|
799
|
+
finally:
|
|
800
|
+
if view is not None:
|
|
801
|
+
view.release()
|
|
802
|
+
if isinstance(value, memoryview):
|
|
803
|
+
try:
|
|
804
|
+
_preflight_scalar(value, budget, "Query binding values")
|
|
805
|
+
return value.tobytes()
|
|
806
|
+
except TrustedLiveProtocolValidationError:
|
|
807
|
+
raise
|
|
808
|
+
except (BufferError, TypeError, ValueError) as error:
|
|
809
|
+
raise TrustedLiveProtocolValidationError(
|
|
810
|
+
"A SQLite blob view could not be snapshotted safely."
|
|
811
|
+
) from error
|
|
812
|
+
_preflight_scalar(value, budget, "Query binding values")
|
|
813
|
+
return value
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _freeze_bindings_with_budget(
|
|
817
|
+
bindings: SqliteBindings,
|
|
818
|
+
budget: _WireSizeBudget,
|
|
819
|
+
) -> SqliteBindings:
|
|
820
|
+
"""Validate and freeze bounded binding references into a shared budget."""
|
|
821
|
+
|
|
822
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query bindings")
|
|
823
|
+
if bindings is None:
|
|
824
|
+
return None
|
|
825
|
+
if isinstance(bindings, Mapping):
|
|
826
|
+
frozen_mapping: dict[str, Any] = {}
|
|
827
|
+
for key, value in bindings.items():
|
|
828
|
+
key_size = _json_string_wire_size(
|
|
829
|
+
key,
|
|
830
|
+
"A named binding key",
|
|
831
|
+
MAX_BINDING_NAME_BYTES,
|
|
832
|
+
)
|
|
833
|
+
budget.consume(
|
|
834
|
+
_WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + key_size,
|
|
835
|
+
"Named query bindings",
|
|
836
|
+
)
|
|
837
|
+
frozen_mapping[key] = _freeze_binding_scalar(value, budget)
|
|
838
|
+
return frozen_mapping
|
|
839
|
+
frozen_values: list[Any] = []
|
|
840
|
+
for value in bindings:
|
|
841
|
+
budget.consume(_WIRE_FIELD_BUDGET_BYTES, "Sequential query bindings")
|
|
842
|
+
frozen_values.append(_freeze_binding_scalar(value, budget))
|
|
843
|
+
return tuple(frozen_values)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _normalize_query_with_budget(
|
|
847
|
+
sql: object,
|
|
848
|
+
bindings: SqliteBindings,
|
|
849
|
+
budget: _WireSizeBudget,
|
|
850
|
+
) -> TrustedQuery:
|
|
851
|
+
if not isinstance(sql, str):
|
|
852
|
+
raise TrustedLiveProtocolValidationError("Query SQL must be text.")
|
|
853
|
+
sql_size = _json_string_wire_size(sql, "Query SQL", MAX_SQL_BYTES)
|
|
854
|
+
budget.consume(
|
|
855
|
+
_WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + sql_size,
|
|
856
|
+
"Query specifications",
|
|
857
|
+
)
|
|
858
|
+
bounded = _bounded_binding_references(bindings)
|
|
859
|
+
frozen = _freeze_bindings_with_budget(bounded, budget)
|
|
860
|
+
return TrustedQuery(sql, frozen)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def normalize_query_specification(
|
|
864
|
+
sql: object,
|
|
865
|
+
bindings: SqliteBindings,
|
|
866
|
+
) -> TrustedQuery:
|
|
867
|
+
"""Build one bounded query before ``TrustedQuery`` can copy raw input."""
|
|
868
|
+
|
|
869
|
+
budget = _new_role_budget(MAX_REQUEST_BYTES)
|
|
870
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query specifications")
|
|
871
|
+
return _normalize_query_with_budget(sql, bindings, budget)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def normalize_query_batch(
|
|
875
|
+
queries: Sequence[TrustedQuery],
|
|
876
|
+
) -> tuple[TrustedQuery, ...]:
|
|
877
|
+
"""Snapshot one finite batch within a single aggregate request budget."""
|
|
878
|
+
|
|
879
|
+
if not isinstance(queries, Sequence) or isinstance(
|
|
880
|
+
queries, (str, bytes, bytearray, memoryview)
|
|
881
|
+
):
|
|
882
|
+
raise TrustedLiveProtocolValidationError(
|
|
883
|
+
"Query specifications must be a bounded sequence."
|
|
884
|
+
)
|
|
885
|
+
try:
|
|
886
|
+
query_count = len(queries)
|
|
887
|
+
except Exception as error:
|
|
888
|
+
raise TrustedLiveProtocolValidationError(
|
|
889
|
+
"Query specifications do not expose a finite size."
|
|
890
|
+
) from error
|
|
891
|
+
if not 1 <= query_count <= MAX_BATCH_QUERIES:
|
|
892
|
+
raise TrustedLiveProtocolValidationError(
|
|
893
|
+
f"A query batch must contain 1 through {MAX_BATCH_QUERIES} statements."
|
|
894
|
+
)
|
|
895
|
+
specifications: list[TrustedQuery] = []
|
|
896
|
+
for index in range(query_count):
|
|
897
|
+
try:
|
|
898
|
+
query = queries[index]
|
|
899
|
+
except Exception as error:
|
|
900
|
+
raise TrustedLiveProtocolValidationError(
|
|
901
|
+
"Query specifications changed while being inspected."
|
|
902
|
+
) from error
|
|
903
|
+
if not isinstance(query, TrustedQuery):
|
|
904
|
+
raise TrustedLiveProtocolValidationError(
|
|
905
|
+
"Query batches accept only TrustedQuery specifications."
|
|
906
|
+
)
|
|
907
|
+
specifications.append(query)
|
|
908
|
+
|
|
909
|
+
budget = _new_role_budget(MAX_REQUEST_BYTES)
|
|
910
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query specifications")
|
|
911
|
+
return tuple(
|
|
912
|
+
_normalize_query_with_budget(query.sql, query.bindings, budget)
|
|
913
|
+
for query in specifications
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _encode_bindings_prevalidated(bindings: SqliteBindings) -> dict[str, JsonValue]:
|
|
918
|
+
if bindings is None:
|
|
919
|
+
return {"kind": "none"}
|
|
920
|
+
if isinstance(bindings, Mapping):
|
|
921
|
+
return {
|
|
922
|
+
"kind": "mapping",
|
|
923
|
+
"items": [[key, _encode_scalar(value)] for key, value in bindings.items()],
|
|
924
|
+
}
|
|
925
|
+
if isinstance(bindings, (str, bytes, bytearray, memoryview)):
|
|
926
|
+
values: Sequence[object] = (bindings,)
|
|
927
|
+
else:
|
|
928
|
+
values = bindings
|
|
929
|
+
return {"kind": "sequence", "values": [_encode_scalar(value) for value in values]}
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def encode_bindings(bindings: SqliteBindings) -> dict[str, JsonValue]:
|
|
933
|
+
budget = _new_role_budget(MAX_REQUEST_BYTES)
|
|
934
|
+
_preflight_bindings(bindings, budget)
|
|
935
|
+
return _encode_bindings_prevalidated(bindings)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def decode_bindings(encoded: object) -> SqliteBindings:
|
|
939
|
+
if not isinstance(encoded, dict) or "kind" not in encoded:
|
|
940
|
+
raise TrustedLiveProtocolValidationError("Query bindings are malformed.")
|
|
941
|
+
kind = encoded["kind"]
|
|
942
|
+
if kind == "none" and set(encoded) == {"kind"}:
|
|
943
|
+
return None
|
|
944
|
+
if kind == "sequence" and set(encoded) == {"kind", "values"}:
|
|
945
|
+
values = encoded["values"]
|
|
946
|
+
if not isinstance(values, list) or len(values) > MAX_BINDINGS_PER_QUERY:
|
|
947
|
+
raise TrustedLiveProtocolValidationError(
|
|
948
|
+
"Sequential query bindings are malformed or oversized."
|
|
949
|
+
)
|
|
950
|
+
return tuple(_decode_scalar(value) for value in values)
|
|
951
|
+
if kind == "mapping" and set(encoded) == {"kind", "items"}:
|
|
952
|
+
items = encoded["items"]
|
|
953
|
+
if not isinstance(items, list) or len(items) > MAX_BINDINGS_PER_QUERY:
|
|
954
|
+
raise TrustedLiveProtocolValidationError(
|
|
955
|
+
"Named query bindings are malformed or oversized."
|
|
956
|
+
)
|
|
957
|
+
result: dict[str, object] = {}
|
|
958
|
+
for item in items:
|
|
959
|
+
if (
|
|
960
|
+
not isinstance(item, list)
|
|
961
|
+
or len(item) != 2
|
|
962
|
+
or not isinstance(item[0], str)
|
|
963
|
+
or not item[0]
|
|
964
|
+
or item[0] in result
|
|
965
|
+
):
|
|
966
|
+
raise TrustedLiveProtocolValidationError(
|
|
967
|
+
"Named query bindings contain an invalid or duplicate key."
|
|
968
|
+
)
|
|
969
|
+
_utf8_size(item[0], "A named binding key", MAX_BINDING_NAME_BYTES)
|
|
970
|
+
result[item[0]] = _decode_scalar(item[1])
|
|
971
|
+
return result
|
|
972
|
+
raise TrustedLiveProtocolValidationError("Query bindings are malformed.")
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _preflight_queries(
|
|
976
|
+
specifications: tuple[TrustedQuery, ...],
|
|
977
|
+
budget: _WireSizeBudget,
|
|
978
|
+
) -> None:
|
|
979
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Query specifications")
|
|
980
|
+
if not specifications or len(specifications) > MAX_BATCH_QUERIES:
|
|
981
|
+
raise TrustedLiveProtocolValidationError(
|
|
982
|
+
f"A query batch must contain 1 through {MAX_BATCH_QUERIES} statements."
|
|
983
|
+
)
|
|
984
|
+
for query in specifications:
|
|
985
|
+
if not isinstance(query, TrustedQuery):
|
|
986
|
+
raise TrustedLiveProtocolValidationError(
|
|
987
|
+
"Query batches accept only TrustedQuery specifications."
|
|
988
|
+
)
|
|
989
|
+
if not isinstance(query.sql, str):
|
|
990
|
+
raise TrustedLiveProtocolValidationError("Query SQL must be text.")
|
|
991
|
+
sql_size = _json_string_wire_size(query.sql, "Query SQL", MAX_SQL_BYTES)
|
|
992
|
+
budget.consume(
|
|
993
|
+
_WIRE_CONTAINER_BUDGET_BYTES + _WIRE_FIELD_BUDGET_BYTES + sql_size,
|
|
994
|
+
"Query specifications",
|
|
995
|
+
)
|
|
996
|
+
_preflight_bindings(query.bindings, budget)
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def encode_queries(queries: Sequence[TrustedQuery]) -> list[JsonValue]:
|
|
1000
|
+
if not isinstance(queries, Sequence) or isinstance(
|
|
1001
|
+
queries, (str, bytes, bytearray, memoryview)
|
|
1002
|
+
):
|
|
1003
|
+
raise TrustedLiveProtocolValidationError(
|
|
1004
|
+
"Query specifications must be a bounded sequence."
|
|
1005
|
+
)
|
|
1006
|
+
query_count = len(queries)
|
|
1007
|
+
if not 1 <= query_count <= MAX_BATCH_QUERIES:
|
|
1008
|
+
raise TrustedLiveProtocolValidationError(
|
|
1009
|
+
f"A query batch must contain 1 through {MAX_BATCH_QUERIES} statements."
|
|
1010
|
+
)
|
|
1011
|
+
specifications = tuple(queries[index] for index in range(query_count))
|
|
1012
|
+
budget = _new_role_budget(MAX_REQUEST_BYTES)
|
|
1013
|
+
_preflight_queries(specifications, budget)
|
|
1014
|
+
return [
|
|
1015
|
+
{
|
|
1016
|
+
"bindings": _encode_bindings_prevalidated(query.bindings),
|
|
1017
|
+
"sql": query.sql,
|
|
1018
|
+
}
|
|
1019
|
+
for query in specifications
|
|
1020
|
+
]
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def decode_queries(encoded: object) -> tuple[TrustedQuery, ...]:
|
|
1024
|
+
if not isinstance(encoded, list) or not encoded or len(encoded) > MAX_BATCH_QUERIES:
|
|
1025
|
+
raise TrustedLiveProtocolValidationError(
|
|
1026
|
+
"The query list is empty, malformed, or oversized."
|
|
1027
|
+
)
|
|
1028
|
+
queries: list[TrustedQuery] = []
|
|
1029
|
+
for item in encoded:
|
|
1030
|
+
if not isinstance(item, dict) or set(item) != {"sql", "bindings"}:
|
|
1031
|
+
raise TrustedLiveProtocolValidationError(
|
|
1032
|
+
"A query specification has invalid fields."
|
|
1033
|
+
)
|
|
1034
|
+
sql = item["sql"]
|
|
1035
|
+
if not isinstance(sql, str):
|
|
1036
|
+
raise TrustedLiveProtocolValidationError("Query SQL must be text.")
|
|
1037
|
+
_utf8_size(sql, "Query SQL", MAX_SQL_BYTES)
|
|
1038
|
+
queries.append(TrustedQuery(sql, decode_bindings(item["bindings"])))
|
|
1039
|
+
return tuple(queries)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def validate_timeout_ms(value: object) -> int:
|
|
1043
|
+
if (
|
|
1044
|
+
type(value) is not int
|
|
1045
|
+
or value < MIN_OPERATION_TIMEOUT_MS
|
|
1046
|
+
or value > MAX_OPERATION_TIMEOUT_MS
|
|
1047
|
+
):
|
|
1048
|
+
raise TrustedLiveProtocolValidationError(
|
|
1049
|
+
"The child operation timeout is outside the bounded protocol range."
|
|
1050
|
+
)
|
|
1051
|
+
return value
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def encode_job_request(
|
|
1055
|
+
session: str,
|
|
1056
|
+
generation: int,
|
|
1057
|
+
operation: str,
|
|
1058
|
+
queries: Sequence[TrustedQuery] | None,
|
|
1059
|
+
timeout_ms: int,
|
|
1060
|
+
) -> bytes:
|
|
1061
|
+
validate_generation(generation, allow_zero=False)
|
|
1062
|
+
validate_timeout_ms(timeout_ms)
|
|
1063
|
+
if operation == "query":
|
|
1064
|
+
encoded_queries = encode_queries(queries or ())
|
|
1065
|
+
if len(encoded_queries) != 1:
|
|
1066
|
+
raise TrustedLiveProtocolValidationError(
|
|
1067
|
+
"A query job must contain exactly one statement."
|
|
1068
|
+
)
|
|
1069
|
+
payload: dict[str, Any] = {
|
|
1070
|
+
"queries": encoded_queries,
|
|
1071
|
+
"timeout_ms": timeout_ms,
|
|
1072
|
+
}
|
|
1073
|
+
elif operation == "query_batch":
|
|
1074
|
+
payload = {
|
|
1075
|
+
"queries": encode_queries(queries or ()),
|
|
1076
|
+
"timeout_ms": timeout_ms,
|
|
1077
|
+
}
|
|
1078
|
+
elif operation == "data_version" and queries is None:
|
|
1079
|
+
payload = {"timeout_ms": timeout_ms}
|
|
1080
|
+
else:
|
|
1081
|
+
raise TrustedLiveProtocolValidationError(
|
|
1082
|
+
"The requested job operation is invalid."
|
|
1083
|
+
)
|
|
1084
|
+
return encode_frame(
|
|
1085
|
+
session,
|
|
1086
|
+
generation,
|
|
1087
|
+
operation,
|
|
1088
|
+
payload,
|
|
1089
|
+
maximum_bytes=MAX_REQUEST_BYTES,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def decode_job_request(
|
|
1094
|
+
envelope: ProtocolEnvelope,
|
|
1095
|
+
) -> tuple[tuple[TrustedQuery, ...] | None, int]:
|
|
1096
|
+
payload = envelope.payload
|
|
1097
|
+
if envelope.operation in {"query", "query_batch"}:
|
|
1098
|
+
if set(payload) != {"queries", "timeout_ms"}:
|
|
1099
|
+
raise TrustedLiveProtocolValidationError(
|
|
1100
|
+
"The query request has invalid fields."
|
|
1101
|
+
)
|
|
1102
|
+
queries = decode_queries(payload["queries"])
|
|
1103
|
+
if envelope.operation == "query" and len(queries) != 1:
|
|
1104
|
+
raise TrustedLiveProtocolValidationError(
|
|
1105
|
+
"A query request must contain exactly one statement."
|
|
1106
|
+
)
|
|
1107
|
+
return queries, validate_timeout_ms(payload["timeout_ms"])
|
|
1108
|
+
if envelope.operation == "data_version":
|
|
1109
|
+
if set(payload) != {"timeout_ms"}:
|
|
1110
|
+
raise TrustedLiveProtocolValidationError(
|
|
1111
|
+
"The data-version request has invalid fields."
|
|
1112
|
+
)
|
|
1113
|
+
return None, validate_timeout_ms(payload["timeout_ms"])
|
|
1114
|
+
raise TrustedLiveProtocolValidationError("The envelope is not a database job.")
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def encode_cancel(session: str, generation: int) -> bytes:
|
|
1118
|
+
validate_generation(generation, allow_zero=False)
|
|
1119
|
+
return encode_frame(
|
|
1120
|
+
session,
|
|
1121
|
+
generation,
|
|
1122
|
+
"cancel",
|
|
1123
|
+
{},
|
|
1124
|
+
maximum_bytes=MAX_CONTROL_BYTES,
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def validate_cancel(envelope: ProtocolEnvelope) -> None:
|
|
1129
|
+
if envelope.operation != "cancel" or envelope.payload:
|
|
1130
|
+
raise TrustedLiveProtocolValidationError(
|
|
1131
|
+
"A cancellation frame has invalid fields."
|
|
1132
|
+
)
|
|
1133
|
+
validate_generation(envelope.generation, allow_zero=False)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def encode_shutdown(session: str, generation: int) -> bytes:
|
|
1137
|
+
validate_generation(generation, allow_zero=False)
|
|
1138
|
+
return encode_frame(
|
|
1139
|
+
session,
|
|
1140
|
+
generation,
|
|
1141
|
+
"shutdown",
|
|
1142
|
+
{},
|
|
1143
|
+
maximum_bytes=MAX_REQUEST_BYTES,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def validate_shutdown(envelope: ProtocolEnvelope) -> None:
|
|
1148
|
+
if envelope.operation != "shutdown" or envelope.payload:
|
|
1149
|
+
raise TrustedLiveProtocolValidationError("A shutdown frame has invalid fields.")
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _identity_field_wire_sizes(identity: object) -> tuple[int, ...]:
|
|
1153
|
+
if not isinstance(identity, tuple):
|
|
1154
|
+
raise TrustedLiveProtocolValidationError("A database file identity is invalid.")
|
|
1155
|
+
valid_shape = (
|
|
1156
|
+
len(identity) == 2 and type(identity[0]) is int and type(identity[1]) is int
|
|
1157
|
+
) or (
|
|
1158
|
+
len(identity) == 3
|
|
1159
|
+
and type(identity[0]) is str
|
|
1160
|
+
and type(identity[2]) is int
|
|
1161
|
+
and type(identity[1]) in {str, int}
|
|
1162
|
+
)
|
|
1163
|
+
if not valid_shape:
|
|
1164
|
+
raise TrustedLiveProtocolValidationError(
|
|
1165
|
+
"A database file identity has an unsupported shape."
|
|
1166
|
+
)
|
|
1167
|
+
sizes: list[int] = []
|
|
1168
|
+
for index, value in enumerate(identity):
|
|
1169
|
+
if type(value) is str:
|
|
1170
|
+
maximum = (
|
|
1171
|
+
MAX_PATH_BYTES
|
|
1172
|
+
if len(identity) == 3 and identity[0] == "birthtime" and index == 1
|
|
1173
|
+
else 1_024
|
|
1174
|
+
)
|
|
1175
|
+
sizes.append(
|
|
1176
|
+
_json_string_wire_size(
|
|
1177
|
+
value,
|
|
1178
|
+
"A database identity field",
|
|
1179
|
+
maximum,
|
|
1180
|
+
)
|
|
1181
|
+
)
|
|
1182
|
+
elif type(value) is int and 0 <= value <= _IDENTITY_INTEGER_MAX:
|
|
1183
|
+
sizes.append(len(str(value)))
|
|
1184
|
+
else:
|
|
1185
|
+
raise TrustedLiveProtocolValidationError(
|
|
1186
|
+
"Database identity integers must be non-negative unsigned "
|
|
1187
|
+
"64-bit values."
|
|
1188
|
+
)
|
|
1189
|
+
return tuple(sizes)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _encode_identity_prevalidated(
|
|
1193
|
+
identity: DatabaseFileIdentity | None,
|
|
1194
|
+
) -> JsonValue:
|
|
1195
|
+
return None if identity is None else list(identity)
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _preflight_identity(
|
|
1199
|
+
identity: DatabaseFileIdentity | None,
|
|
1200
|
+
budget: _WireSizeBudget,
|
|
1201
|
+
description: str,
|
|
1202
|
+
) -> None:
|
|
1203
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, description)
|
|
1204
|
+
if identity is None:
|
|
1205
|
+
return
|
|
1206
|
+
for value_size in _identity_field_wire_sizes(identity):
|
|
1207
|
+
budget.consume(
|
|
1208
|
+
_WIRE_FIELD_BUDGET_BYTES + value_size,
|
|
1209
|
+
description,
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _decode_identity(encoded: object) -> DatabaseFileIdentity | None:
|
|
1214
|
+
if encoded is None:
|
|
1215
|
+
return None
|
|
1216
|
+
if not isinstance(encoded, list) or len(encoded) not in {2, 3}:
|
|
1217
|
+
raise TrustedLiveProtocolValidationError("A database file identity is invalid.")
|
|
1218
|
+
identity = tuple(encoded)
|
|
1219
|
+
_identity_field_wire_sizes(identity)
|
|
1220
|
+
if len(identity) == 2 and all(type(value) is int for value in identity):
|
|
1221
|
+
return (identity[0], identity[1])
|
|
1222
|
+
if (
|
|
1223
|
+
len(identity) == 3
|
|
1224
|
+
and isinstance(identity[0], str)
|
|
1225
|
+
and type(identity[1]) is int
|
|
1226
|
+
and type(identity[2]) is int
|
|
1227
|
+
):
|
|
1228
|
+
return (identity[0], identity[1], identity[2])
|
|
1229
|
+
if (
|
|
1230
|
+
len(identity) == 3
|
|
1231
|
+
and isinstance(identity[0], str)
|
|
1232
|
+
and isinstance(identity[1], str)
|
|
1233
|
+
and type(identity[2]) is int
|
|
1234
|
+
):
|
|
1235
|
+
return (identity[0], identity[1], identity[2])
|
|
1236
|
+
raise TrustedLiveProtocolValidationError(
|
|
1237
|
+
"A database file identity has an unsupported shape."
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _validate_database_instance_path(value: object, description: str) -> str:
|
|
1242
|
+
if type(value) is not str or not value or "\x00" in value:
|
|
1243
|
+
raise TrustedLiveProtocolValidationError(
|
|
1244
|
+
f"{description} must be non-empty text without NUL bytes."
|
|
1245
|
+
)
|
|
1246
|
+
_utf8_size(value, description, MAX_PATH_BYTES)
|
|
1247
|
+
return value
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _preflight_database_instance(
|
|
1251
|
+
instance: DatabaseInstance | None,
|
|
1252
|
+
budget: _WireSizeBudget,
|
|
1253
|
+
) -> None:
|
|
1254
|
+
budget.consume(_WIRE_CONTAINER_BUDGET_BYTES, "Database instance")
|
|
1255
|
+
if instance is None:
|
|
1256
|
+
return
|
|
1257
|
+
if not isinstance(instance, DatabaseInstance):
|
|
1258
|
+
raise TrustedLiveProtocolValidationError(
|
|
1259
|
+
"The expected database instance has an invalid type."
|
|
1260
|
+
)
|
|
1261
|
+
logical_path = _validate_database_instance_path(
|
|
1262
|
+
instance.logical_path,
|
|
1263
|
+
"The logical database path",
|
|
1264
|
+
)
|
|
1265
|
+
resolved_path = _validate_database_instance_path(
|
|
1266
|
+
instance.resolved_path,
|
|
1267
|
+
"The resolved database path",
|
|
1268
|
+
)
|
|
1269
|
+
logical_path_size = _json_string_wire_size(
|
|
1270
|
+
logical_path,
|
|
1271
|
+
"The logical database path",
|
|
1272
|
+
MAX_PATH_BYTES,
|
|
1273
|
+
)
|
|
1274
|
+
resolved_path_size = _json_string_wire_size(
|
|
1275
|
+
resolved_path,
|
|
1276
|
+
"The resolved database path",
|
|
1277
|
+
MAX_PATH_BYTES,
|
|
1278
|
+
)
|
|
1279
|
+
budget.consume(
|
|
1280
|
+
(2 * _WIRE_FIELD_BUDGET_BYTES) + logical_path_size + resolved_path_size,
|
|
1281
|
+
"Database instance paths",
|
|
1282
|
+
)
|
|
1283
|
+
if len(instance.sidecar_identities) > MAX_DATABASE_SIDECAR_IDENTITIES:
|
|
1284
|
+
raise TrustedLiveProtocolValidationError(
|
|
1285
|
+
"A database instance has too many sidecar identities."
|
|
1286
|
+
)
|
|
1287
|
+
_preflight_identity(instance.identity, budget, "Database main-file identity")
|
|
1288
|
+
for identity in instance.sidecar_identities:
|
|
1289
|
+
_preflight_identity(identity, budget, "Database sidecar identities")
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def encode_database_instance(instance: DatabaseInstance | None) -> JsonValue:
|
|
1293
|
+
budget = _new_role_budget(MAX_REQUEST_BYTES)
|
|
1294
|
+
_preflight_database_instance(instance, budget)
|
|
1295
|
+
if instance is None:
|
|
1296
|
+
return None
|
|
1297
|
+
sidecars = [
|
|
1298
|
+
_encode_identity_prevalidated(identity)
|
|
1299
|
+
for identity in instance.sidecar_identities
|
|
1300
|
+
]
|
|
1301
|
+
sidecars.sort(key=lambda value: json.dumps(value, separators=(",", ":")))
|
|
1302
|
+
return {
|
|
1303
|
+
"identity": _encode_identity_prevalidated(instance.identity),
|
|
1304
|
+
"logical_path": instance.logical_path,
|
|
1305
|
+
"resolved_path": instance.resolved_path,
|
|
1306
|
+
"sidecar_identities": sidecars,
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def decode_database_instance(encoded: object) -> DatabaseInstance | None:
|
|
1311
|
+
if encoded is None:
|
|
1312
|
+
return None
|
|
1313
|
+
if not isinstance(encoded, dict) or set(encoded) != {
|
|
1314
|
+
"identity",
|
|
1315
|
+
"logical_path",
|
|
1316
|
+
"resolved_path",
|
|
1317
|
+
"sidecar_identities",
|
|
1318
|
+
}:
|
|
1319
|
+
raise TrustedLiveProtocolValidationError(
|
|
1320
|
+
"A database instance has invalid fields."
|
|
1321
|
+
)
|
|
1322
|
+
logical_path = encoded["logical_path"]
|
|
1323
|
+
resolved_path = encoded["resolved_path"]
|
|
1324
|
+
sidecars = encoded["sidecar_identities"]
|
|
1325
|
+
logical_path = _validate_database_instance_path(
|
|
1326
|
+
logical_path,
|
|
1327
|
+
"The logical database path",
|
|
1328
|
+
)
|
|
1329
|
+
resolved_path = _validate_database_instance_path(
|
|
1330
|
+
resolved_path,
|
|
1331
|
+
"The resolved database path",
|
|
1332
|
+
)
|
|
1333
|
+
if (
|
|
1334
|
+
not isinstance(sidecars, list)
|
|
1335
|
+
or len(sidecars) > MAX_DATABASE_SIDECAR_IDENTITIES
|
|
1336
|
+
):
|
|
1337
|
+
raise TrustedLiveProtocolValidationError(
|
|
1338
|
+
"Database sidecar identities are malformed or oversized."
|
|
1339
|
+
)
|
|
1340
|
+
decoded_sidecars = [_decode_identity(identity) for identity in sidecars]
|
|
1341
|
+
if any(identity is None for identity in decoded_sidecars):
|
|
1342
|
+
raise TrustedLiveProtocolValidationError(
|
|
1343
|
+
"A sidecar identity must not be absent inside the identity list."
|
|
1344
|
+
)
|
|
1345
|
+
sidecar_set = frozenset(decoded_sidecars)
|
|
1346
|
+
if len(sidecar_set) != len(decoded_sidecars):
|
|
1347
|
+
raise TrustedLiveProtocolValidationError(
|
|
1348
|
+
"Database sidecar identities must be unique."
|
|
1349
|
+
)
|
|
1350
|
+
return DatabaseInstance(
|
|
1351
|
+
logical_path=logical_path,
|
|
1352
|
+
resolved_path=resolved_path,
|
|
1353
|
+
identity=_decode_identity(encoded["identity"]),
|
|
1354
|
+
sidecar_identities=sidecar_set, # type: ignore[arg-type]
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _validate_startup_database_path(value: object) -> str:
|
|
1359
|
+
if type(value) is not str or not value or "\x00" in value:
|
|
1360
|
+
raise TrustedLiveProtocolValidationError(
|
|
1361
|
+
"The startup database path must be non-empty text without NUL bytes."
|
|
1362
|
+
)
|
|
1363
|
+
_utf8_size(value, "The startup database path", MAX_PATH_BYTES)
|
|
1364
|
+
return value
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _validate_startup_integer(
|
|
1368
|
+
value: object,
|
|
1369
|
+
*,
|
|
1370
|
+
minimum: int,
|
|
1371
|
+
maximum: int,
|
|
1372
|
+
description: str,
|
|
1373
|
+
) -> int:
|
|
1374
|
+
if type(value) is not int or value < minimum or value > maximum:
|
|
1375
|
+
raise TrustedLiveProtocolValidationError(
|
|
1376
|
+
f"{description} must be an integer from {minimum} through {maximum}."
|
|
1377
|
+
)
|
|
1378
|
+
return value
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def encode_startup_request(
|
|
1382
|
+
session: str,
|
|
1383
|
+
first_generation: int,
|
|
1384
|
+
database_path: str,
|
|
1385
|
+
expected_database_instance: DatabaseInstance | None,
|
|
1386
|
+
busy_timeout_ms: int,
|
|
1387
|
+
operation_timeout_ms: int,
|
|
1388
|
+
orphan_grace_ms: int,
|
|
1389
|
+
) -> bytes:
|
|
1390
|
+
"""Encode all application startup configuration in one bounded frame."""
|
|
1391
|
+
|
|
1392
|
+
validate_generation(first_generation, allow_zero=False)
|
|
1393
|
+
validated_path = _validate_startup_database_path(database_path)
|
|
1394
|
+
validated_busy_timeout = _validate_startup_integer(
|
|
1395
|
+
busy_timeout_ms,
|
|
1396
|
+
minimum=0,
|
|
1397
|
+
maximum=_MAX_BUSY_TIMEOUT_MS,
|
|
1398
|
+
description="The startup busy timeout",
|
|
1399
|
+
)
|
|
1400
|
+
validated_operation_timeout = _validate_startup_integer(
|
|
1401
|
+
operation_timeout_ms,
|
|
1402
|
+
minimum=_MIN_STARTUP_TIMEOUT_MS,
|
|
1403
|
+
maximum=MAX_OPERATION_TIMEOUT_MS,
|
|
1404
|
+
description="The startup operation timeout",
|
|
1405
|
+
)
|
|
1406
|
+
validated_orphan_grace = _validate_startup_integer(
|
|
1407
|
+
orphan_grace_ms,
|
|
1408
|
+
minimum=_MIN_ORPHAN_GRACE_MS,
|
|
1409
|
+
maximum=MAX_OPERATION_TIMEOUT_MS,
|
|
1410
|
+
description="The startup orphan grace period",
|
|
1411
|
+
)
|
|
1412
|
+
budget = _new_role_budget(MAX_REQUEST_BYTES)
|
|
1413
|
+
budget.consume(
|
|
1414
|
+
_WIRE_CONTAINER_BUDGET_BYTES
|
|
1415
|
+
+ (6 * _WIRE_FIELD_BUDGET_BYTES)
|
|
1416
|
+
+ _json_string_wire_size(
|
|
1417
|
+
validated_path,
|
|
1418
|
+
"The startup database path",
|
|
1419
|
+
MAX_PATH_BYTES,
|
|
1420
|
+
),
|
|
1421
|
+
"The startup request",
|
|
1422
|
+
)
|
|
1423
|
+
_preflight_database_instance(expected_database_instance, budget)
|
|
1424
|
+
payload = {
|
|
1425
|
+
"busy_timeout_ms": validated_busy_timeout,
|
|
1426
|
+
"database_path": validated_path,
|
|
1427
|
+
"expected_database_instance": encode_database_instance(
|
|
1428
|
+
expected_database_instance
|
|
1429
|
+
),
|
|
1430
|
+
"first_generation": first_generation,
|
|
1431
|
+
"operation_timeout_ms": validated_operation_timeout,
|
|
1432
|
+
"orphan_grace_ms": validated_orphan_grace,
|
|
1433
|
+
}
|
|
1434
|
+
return encode_frame(
|
|
1435
|
+
session,
|
|
1436
|
+
0,
|
|
1437
|
+
"startup",
|
|
1438
|
+
payload,
|
|
1439
|
+
maximum_bytes=MAX_REQUEST_BYTES,
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def decode_startup_request(
|
|
1444
|
+
envelope: ProtocolEnvelope,
|
|
1445
|
+
) -> tuple[int, str, DatabaseInstance | None, int, int, int]:
|
|
1446
|
+
"""Validate and decode one generation-zero startup request."""
|
|
1447
|
+
|
|
1448
|
+
if envelope.operation != "startup" or envelope.generation != 0:
|
|
1449
|
+
raise TrustedLiveProtocolValidationError(
|
|
1450
|
+
"The startup request must use operation 'startup' and generation zero."
|
|
1451
|
+
)
|
|
1452
|
+
payload = envelope.payload
|
|
1453
|
+
if set(payload) != {
|
|
1454
|
+
"first_generation",
|
|
1455
|
+
"database_path",
|
|
1456
|
+
"expected_database_instance",
|
|
1457
|
+
"busy_timeout_ms",
|
|
1458
|
+
"operation_timeout_ms",
|
|
1459
|
+
"orphan_grace_ms",
|
|
1460
|
+
}:
|
|
1461
|
+
raise TrustedLiveProtocolValidationError(
|
|
1462
|
+
"The startup request has invalid fields."
|
|
1463
|
+
)
|
|
1464
|
+
first_generation = validate_generation(
|
|
1465
|
+
payload["first_generation"],
|
|
1466
|
+
allow_zero=False,
|
|
1467
|
+
)
|
|
1468
|
+
database_path = _validate_startup_database_path(payload["database_path"])
|
|
1469
|
+
expected_instance = decode_database_instance(payload["expected_database_instance"])
|
|
1470
|
+
busy_timeout_ms = _validate_startup_integer(
|
|
1471
|
+
payload["busy_timeout_ms"],
|
|
1472
|
+
minimum=0,
|
|
1473
|
+
maximum=_MAX_BUSY_TIMEOUT_MS,
|
|
1474
|
+
description="The startup busy timeout",
|
|
1475
|
+
)
|
|
1476
|
+
operation_timeout_ms = _validate_startup_integer(
|
|
1477
|
+
payload["operation_timeout_ms"],
|
|
1478
|
+
minimum=_MIN_STARTUP_TIMEOUT_MS,
|
|
1479
|
+
maximum=MAX_OPERATION_TIMEOUT_MS,
|
|
1480
|
+
description="The startup operation timeout",
|
|
1481
|
+
)
|
|
1482
|
+
orphan_grace_ms = _validate_startup_integer(
|
|
1483
|
+
payload["orphan_grace_ms"],
|
|
1484
|
+
minimum=_MIN_ORPHAN_GRACE_MS,
|
|
1485
|
+
maximum=MAX_OPERATION_TIMEOUT_MS,
|
|
1486
|
+
description="The startup orphan grace period",
|
|
1487
|
+
)
|
|
1488
|
+
return (
|
|
1489
|
+
first_generation,
|
|
1490
|
+
database_path,
|
|
1491
|
+
expected_instance,
|
|
1492
|
+
busy_timeout_ms,
|
|
1493
|
+
operation_timeout_ms,
|
|
1494
|
+
orphan_grace_ms,
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def encode_source_identity(source: TrustedLiveSourceIdentity) -> dict[str, JsonValue]:
|
|
1499
|
+
if not isinstance(source, TrustedLiveSourceIdentity):
|
|
1500
|
+
raise TrustedLiveProtocolValidationError("The source identity is invalid.")
|
|
1501
|
+
if source.journal_mode not in {"wal", "rollback"}:
|
|
1502
|
+
raise TrustedLiveProtocolValidationError(
|
|
1503
|
+
"The source journal mode is unsupported."
|
|
1504
|
+
)
|
|
1505
|
+
budget = _new_role_budget(MAX_REPLY_BYTES)
|
|
1506
|
+
_preflight_database_instance(source.database_instance, budget)
|
|
1507
|
+
_preflight_identity(source.journal_identity, budget, "Journal identity")
|
|
1508
|
+
_preflight_identity(source.shm_identity, budget, "SHM identity")
|
|
1509
|
+
_preflight_identity(source.wal_identity, budget, "WAL identity")
|
|
1510
|
+
return {
|
|
1511
|
+
"database_instance": encode_database_instance(source.database_instance),
|
|
1512
|
+
"journal_identity": _encode_identity_prevalidated(source.journal_identity),
|
|
1513
|
+
"journal_mode": source.journal_mode,
|
|
1514
|
+
"shm_identity": _encode_identity_prevalidated(source.shm_identity),
|
|
1515
|
+
"wal_identity": _encode_identity_prevalidated(source.wal_identity),
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def decode_source_identity(encoded: object) -> TrustedLiveSourceIdentity:
|
|
1520
|
+
if not isinstance(encoded, dict) or set(encoded) != {
|
|
1521
|
+
"database_instance",
|
|
1522
|
+
"wal_identity",
|
|
1523
|
+
"shm_identity",
|
|
1524
|
+
"journal_identity",
|
|
1525
|
+
"journal_mode",
|
|
1526
|
+
}:
|
|
1527
|
+
raise TrustedLiveProtocolValidationError(
|
|
1528
|
+
"The trusted source identity has invalid fields."
|
|
1529
|
+
)
|
|
1530
|
+
instance = decode_database_instance(encoded["database_instance"])
|
|
1531
|
+
if instance is None or encoded["journal_mode"] not in {"wal", "rollback"}:
|
|
1532
|
+
raise TrustedLiveProtocolValidationError(
|
|
1533
|
+
"The trusted source identity is incomplete."
|
|
1534
|
+
)
|
|
1535
|
+
return TrustedLiveSourceIdentity(
|
|
1536
|
+
database_instance=instance,
|
|
1537
|
+
wal_identity=_decode_identity(encoded["wal_identity"]),
|
|
1538
|
+
shm_identity=_decode_identity(encoded["shm_identity"]),
|
|
1539
|
+
journal_identity=_decode_identity(encoded["journal_identity"]),
|
|
1540
|
+
journal_mode=encoded["journal_mode"],
|
|
1541
|
+
)
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _preflight_query_results(
|
|
1545
|
+
materialised: tuple[TrustedQueryResult, ...],
|
|
1546
|
+
) -> None:
|
|
1547
|
+
try:
|
|
1548
|
+
preflight_trusted_query_results(materialised)
|
|
1549
|
+
except TrustedLiveReaderError as error:
|
|
1550
|
+
raise TrustedLiveProtocolValidationError(str(error)) from error
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def encode_query_results(results: Sequence[TrustedQueryResult]) -> list[JsonValue]:
|
|
1554
|
+
if not isinstance(results, Sequence) or isinstance(
|
|
1555
|
+
results, (str, bytes, bytearray, memoryview)
|
|
1556
|
+
):
|
|
1557
|
+
raise TrustedLiveProtocolValidationError(
|
|
1558
|
+
"Query results must be a bounded sequence."
|
|
1559
|
+
)
|
|
1560
|
+
result_count = len(results)
|
|
1561
|
+
if not 1 <= result_count <= MAX_BATCH_QUERIES:
|
|
1562
|
+
raise TrustedLiveProtocolValidationError(
|
|
1563
|
+
"The helper returned an empty or oversized result batch."
|
|
1564
|
+
)
|
|
1565
|
+
materialised = tuple(results[index] for index in range(result_count))
|
|
1566
|
+
_preflight_query_results(materialised)
|
|
1567
|
+
encoded_results: list[JsonValue] = []
|
|
1568
|
+
for result in materialised:
|
|
1569
|
+
columns: list[JsonValue] = list(result.columns)
|
|
1570
|
+
rows: list[JsonValue] = []
|
|
1571
|
+
for row in result.rows:
|
|
1572
|
+
rows.append([_encode_scalar(value) for value in row])
|
|
1573
|
+
encoded_results.append({"columns": columns, "rows": rows})
|
|
1574
|
+
return encoded_results
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def decode_query_results(encoded: object) -> tuple[TrustedQueryResult, ...]:
|
|
1578
|
+
if not isinstance(encoded, list) or not encoded or len(encoded) > MAX_BATCH_QUERIES:
|
|
1579
|
+
raise TrustedLiveProtocolValidationError(
|
|
1580
|
+
"The result batch is empty, malformed, or oversized."
|
|
1581
|
+
)
|
|
1582
|
+
results: list[TrustedQueryResult] = []
|
|
1583
|
+
total_cells = 0
|
|
1584
|
+
for item in encoded:
|
|
1585
|
+
if not isinstance(item, dict) or set(item) != {"columns", "rows"}:
|
|
1586
|
+
raise TrustedLiveProtocolValidationError("A result has invalid fields.")
|
|
1587
|
+
columns = item["columns"]
|
|
1588
|
+
rows = item["rows"]
|
|
1589
|
+
if (
|
|
1590
|
+
not isinstance(columns, list)
|
|
1591
|
+
or len(columns) > MAX_COLUMNS_PER_RESULT
|
|
1592
|
+
or not all(isinstance(column, str) for column in columns)
|
|
1593
|
+
):
|
|
1594
|
+
raise TrustedLiveProtocolValidationError(
|
|
1595
|
+
"Result columns are malformed or oversized."
|
|
1596
|
+
)
|
|
1597
|
+
for column in columns:
|
|
1598
|
+
_utf8_size(column, "A result column name", MAX_BINDING_NAME_BYTES)
|
|
1599
|
+
if not isinstance(rows, list) or len(rows) > MAX_ROWS_PER_RESULT:
|
|
1600
|
+
raise TrustedLiveProtocolValidationError(
|
|
1601
|
+
"Result rows are malformed or oversized."
|
|
1602
|
+
)
|
|
1603
|
+
decoded_rows: list[tuple[object, ...]] = []
|
|
1604
|
+
for row in rows:
|
|
1605
|
+
if not isinstance(row, list) or len(row) != len(columns):
|
|
1606
|
+
raise TrustedLiveProtocolValidationError(
|
|
1607
|
+
"A result row has the wrong shape."
|
|
1608
|
+
)
|
|
1609
|
+
total_cells += len(row)
|
|
1610
|
+
if total_cells > MAX_CELLS_PER_REPLY:
|
|
1611
|
+
raise TrustedLiveProtocolValidationError(
|
|
1612
|
+
"The reply contains too many SQLite cells."
|
|
1613
|
+
)
|
|
1614
|
+
decoded_rows.append(tuple(_decode_scalar(value) for value in row))
|
|
1615
|
+
results.append(TrustedQueryResult(tuple(columns), tuple(decoded_rows)))
|
|
1616
|
+
return tuple(results)
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def encode_success_reply(
|
|
1620
|
+
session: str,
|
|
1621
|
+
generation: int,
|
|
1622
|
+
operation: str,
|
|
1623
|
+
payload: Mapping[str, Any] | None = None,
|
|
1624
|
+
) -> bytes:
|
|
1625
|
+
body: dict[str, Any] = {"status": "ok"}
|
|
1626
|
+
if payload:
|
|
1627
|
+
body.update(payload)
|
|
1628
|
+
return encode_frame(
|
|
1629
|
+
session,
|
|
1630
|
+
generation,
|
|
1631
|
+
operation,
|
|
1632
|
+
body,
|
|
1633
|
+
maximum_bytes=MAX_REPLY_BYTES,
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def encode_error_reply(
|
|
1638
|
+
session: str,
|
|
1639
|
+
generation: int,
|
|
1640
|
+
operation: str,
|
|
1641
|
+
code: str,
|
|
1642
|
+
message: str,
|
|
1643
|
+
) -> bytes:
|
|
1644
|
+
if code not in _ERROR_CODES:
|
|
1645
|
+
raise TrustedLiveProtocolValidationError("The helper error code is unknown.")
|
|
1646
|
+
if not isinstance(message, str):
|
|
1647
|
+
message = "The trusted helper failed without a valid error message."
|
|
1648
|
+
encoded_message = message.encode("utf-8", errors="replace")
|
|
1649
|
+
if len(encoded_message) > MAX_ERROR_MESSAGE_BYTES:
|
|
1650
|
+
encoded_message = encoded_message[:MAX_ERROR_MESSAGE_BYTES]
|
|
1651
|
+
while True:
|
|
1652
|
+
try:
|
|
1653
|
+
message = encoded_message.decode("utf-8")
|
|
1654
|
+
break
|
|
1655
|
+
except UnicodeDecodeError as error:
|
|
1656
|
+
encoded_message = encoded_message[: error.start]
|
|
1657
|
+
message += "…"
|
|
1658
|
+
return encode_frame(
|
|
1659
|
+
session,
|
|
1660
|
+
generation,
|
|
1661
|
+
operation,
|
|
1662
|
+
{"status": "error", "code": code, "message": message},
|
|
1663
|
+
maximum_bytes=MAX_REPLY_BYTES,
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def decode_reply_payload(envelope: ProtocolEnvelope) -> tuple[str, dict[str, Any]]:
|
|
1668
|
+
payload = envelope.payload
|
|
1669
|
+
status = payload.get("status")
|
|
1670
|
+
if status == "error":
|
|
1671
|
+
if set(payload) != {"status", "code", "message"}:
|
|
1672
|
+
raise TrustedLiveProtocolValidationError(
|
|
1673
|
+
"An error reply has invalid fields."
|
|
1674
|
+
)
|
|
1675
|
+
code = payload["code"]
|
|
1676
|
+
message = payload["message"]
|
|
1677
|
+
if code not in _ERROR_CODES or not isinstance(message, str):
|
|
1678
|
+
raise TrustedLiveProtocolValidationError(
|
|
1679
|
+
"An error reply has an unknown code or invalid message."
|
|
1680
|
+
)
|
|
1681
|
+
_utf8_size(message, "A helper error message", MAX_ERROR_MESSAGE_BYTES + 4)
|
|
1682
|
+
return "error", {"code": code, "message": message}
|
|
1683
|
+
if status != "ok":
|
|
1684
|
+
raise TrustedLiveProtocolValidationError(
|
|
1685
|
+
"A helper reply has an invalid status."
|
|
1686
|
+
)
|
|
1687
|
+
return "ok", payload
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def validate_startup_success(payload: Mapping[str, Any]) -> TrustedLiveSourceIdentity:
|
|
1691
|
+
if set(payload) != {"status", "source"}:
|
|
1692
|
+
raise TrustedLiveProtocolValidationError(
|
|
1693
|
+
"A startup success reply has invalid fields."
|
|
1694
|
+
)
|
|
1695
|
+
return decode_source_identity(payload["source"])
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def validate_shutdown_success(payload: Mapping[str, Any]) -> None:
|
|
1699
|
+
if set(payload) != {"status"}:
|
|
1700
|
+
raise TrustedLiveProtocolValidationError(
|
|
1701
|
+
"A shutdown success reply has invalid fields."
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def validate_job_success(
|
|
1706
|
+
operation: str,
|
|
1707
|
+
payload: Mapping[str, Any],
|
|
1708
|
+
) -> TrustedQueryResult | tuple[TrustedQueryResult, ...] | int:
|
|
1709
|
+
if operation in {"query", "query_batch"}:
|
|
1710
|
+
if set(payload) != {"status", "results"}:
|
|
1711
|
+
raise TrustedLiveProtocolValidationError(
|
|
1712
|
+
"A query success reply has invalid fields."
|
|
1713
|
+
)
|
|
1714
|
+
results = decode_query_results(payload["results"])
|
|
1715
|
+
if operation == "query":
|
|
1716
|
+
if len(results) != 1:
|
|
1717
|
+
raise TrustedLiveProtocolValidationError(
|
|
1718
|
+
"A query reply must contain exactly one result."
|
|
1719
|
+
)
|
|
1720
|
+
return results[0]
|
|
1721
|
+
return results
|
|
1722
|
+
if operation == "data_version":
|
|
1723
|
+
if set(payload) != {"status", "value"}:
|
|
1724
|
+
raise TrustedLiveProtocolValidationError(
|
|
1725
|
+
"A data-version success reply has invalid fields."
|
|
1726
|
+
)
|
|
1727
|
+
value = payload["value"]
|
|
1728
|
+
if type(value) is not int or value < 0 or value > _INTEGER_MAX:
|
|
1729
|
+
raise TrustedLiveProtocolValidationError(
|
|
1730
|
+
"The data-version reply is invalid."
|
|
1731
|
+
)
|
|
1732
|
+
return value
|
|
1733
|
+
raise TrustedLiveProtocolValidationError(
|
|
1734
|
+
"A success reply used an invalid database operation."
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def error_code_is_terminal(code: str) -> bool:
|
|
1739
|
+
return code not in {
|
|
1740
|
+
"sql_rejected",
|
|
1741
|
+
"query_failed",
|
|
1742
|
+
"result_limit",
|
|
1743
|
+
"busy_timeout",
|
|
1744
|
+
"cancelled",
|
|
1745
|
+
}
|