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,2808 @@
|
|
|
1
|
+
"""Physically read-only access to a trusted live QCoDeS database.
|
|
2
|
+
|
|
3
|
+
This finite backend is owned by the isolated helper process. Application
|
|
4
|
+
metadata, derived work, and explicit plots access it through the read broker.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import stat
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import weakref
|
|
19
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from types import MappingProxyType, TracebackType
|
|
24
|
+
from typing import Any, TypeAlias
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
|
|
27
|
+
import apsw
|
|
28
|
+
import apsw.ext
|
|
29
|
+
|
|
30
|
+
from qplot.datahandling.file_identity import (
|
|
31
|
+
DatabaseFileIdentity,
|
|
32
|
+
DatabaseInstance,
|
|
33
|
+
FileIdentityHandleCloseError,
|
|
34
|
+
canonical_database_path,
|
|
35
|
+
checked_path_bound_file_identity,
|
|
36
|
+
database_instances_differ,
|
|
37
|
+
database_sidecar_identities,
|
|
38
|
+
logical_database_path,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
TRUSTED_READER_APSW_VERSION = "3.53.4.0"
|
|
42
|
+
TRUSTED_READER_NATIVE_VERSION = "1.0.0"
|
|
43
|
+
TRUSTED_READER_SQLITE_VERSION = "3.53.4"
|
|
44
|
+
TRUSTED_READER_SQLITE_SOURCE_ID = (
|
|
45
|
+
"2026-07-24 19:02:57 "
|
|
46
|
+
"bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc"
|
|
47
|
+
)
|
|
48
|
+
TRUSTED_READER_VFS_NAME = "qplot-trusted-live-v2"
|
|
49
|
+
_NATIVE_ENTRYPOINT = "sqlite3_qplot_trusted_vfs_init"
|
|
50
|
+
_SQLITE_HEADER_READ_VERSION_OFFSET = 18
|
|
51
|
+
_SQLITE_HEADER_WRITE_VERSION_OFFSET = 19
|
|
52
|
+
_SQLITE_HEADER_PREFIX_BYTES = 100
|
|
53
|
+
_SQLITE_ROLLBACK_FORMAT = 1
|
|
54
|
+
_SQLITE_WAL_FORMAT = 2
|
|
55
|
+
DEFAULT_TRUSTED_OPERATION_TIMEOUT_SECONDS = 5.0
|
|
56
|
+
TRUSTED_LIVE_MAX_REPLY_BYTES = 32 * 1024 * 1024
|
|
57
|
+
TRUSTED_LIVE_MAX_BATCH_QUERIES = 128
|
|
58
|
+
TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT = 4_096
|
|
59
|
+
TRUSTED_LIVE_MAX_ROWS_PER_RESULT = 250_000
|
|
60
|
+
TRUSTED_LIVE_MAX_CELLS_PER_REPLY = 1_000_000
|
|
61
|
+
TRUSTED_LIVE_MAX_SCALAR_BYTES = 4 * 1024 * 1024
|
|
62
|
+
TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES = 1_024
|
|
63
|
+
TRUSTED_LIVE_MAX_TRANSIENT_RAW_ROW_BYTES = 8 * 1024 * 1024
|
|
64
|
+
# APSW returns ordinary SQLite values in a Python tuple. Four is the maximum
|
|
65
|
+
# UTF-8-to-PEP-393 payload expansion. The per-column allowance covers the
|
|
66
|
+
# scalar object/header/terminator, a tuple reference, and fixed-size SQLite
|
|
67
|
+
# integer/float/NULL objects on supported 64-bit CPython; the fixed allowance
|
|
68
|
+
# covers the tuple's logical object size. This is a conservative logical
|
|
69
|
+
# Python-object/payload envelope, not a bound on allocator-reserved bytes,
|
|
70
|
+
# size-class rounding, process RSS, arenas, fragmentation, or SQLite VM memory.
|
|
71
|
+
# The statement limit also satisfies the independent raw-payload bound.
|
|
72
|
+
_TRUSTED_LIVE_PYTHON_TEXT_EXPANSION = 4
|
|
73
|
+
_TRUSTED_LIVE_PYTHON_FIXED_BYTES_PER_COLUMN = 512
|
|
74
|
+
_TRUSTED_LIVE_PYTHON_ROW_FIXED_BYTES = 4 * 1024
|
|
75
|
+
TRUSTED_LIVE_MAX_TRANSIENT_PYTHON_ROW_BYTES = 32 * 1024 * 1024
|
|
76
|
+
_BUSY_RETRY_QUANTUM_SECONDS = 0.01
|
|
77
|
+
_PROGRESS_HANDLER_STEPS = 1_000
|
|
78
|
+
_RESULT_FRAME_ENVELOPE_RESERVE_BYTES = 512
|
|
79
|
+
_RESULT_WIRE_CONTAINER_BUDGET_BYTES = 32
|
|
80
|
+
_RESULT_WIRE_FIELD_BUDGET_BYTES = 16
|
|
81
|
+
_SQLITE_INTEGER_MIN = -(1 << 63)
|
|
82
|
+
_SQLITE_INTEGER_MAX = (1 << 63) - 1
|
|
83
|
+
_PROCESS_SESSION_MUTEX = threading.Lock()
|
|
84
|
+
_PROCESS_SESSION_OWNER: object | None = None
|
|
85
|
+
_PROCESS_SESSION_QUARANTINE_REASON: str | None = None
|
|
86
|
+
SqliteBinding: TypeAlias = Any
|
|
87
|
+
SqliteBindings: TypeAlias = Sequence[SqliteBinding] | Mapping[str, SqliteBinding] | None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TrustedLiveReaderError(RuntimeError):
|
|
91
|
+
"""Base error for the trusted live-reader backend."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TrustedLiveReaderUnavailableError(TrustedLiveReaderError):
|
|
95
|
+
"""Raised when the pinned native reader boundary is unavailable."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TrustedLiveSourceError(TrustedLiveReaderError):
|
|
99
|
+
"""Raised when the selected source is not a safe direct-read target."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TrustedLiveUnsupportedSourceError(TrustedLiveSourceError):
|
|
103
|
+
"""Raised for an unsupported platform, path, filesystem, or file type."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TrustedLiveSourceChangedError(TrustedLiveSourceError):
|
|
107
|
+
"""Raised only when checked OS identities prove a source change."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TrustedLiveSourceIOError(TrustedLiveSourceError):
|
|
111
|
+
"""Raised for source I/O failures not proven to be identity changes."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TrustedLiveSqlRejectedError(TrustedLiveReaderError):
|
|
115
|
+
"""Raised when SQL is outside the backend's read-only query surface."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TrustedLiveQueryError(TrustedLiveReaderError):
|
|
119
|
+
"""Raised when an allowed query cannot be prepared or executed."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TrustedLiveResultLimitError(TrustedLiveReaderError):
|
|
123
|
+
"""Raised before a query result can exceed its materialisation budget."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TrustedLiveBusyTimeoutError(TrustedLiveReaderError):
|
|
127
|
+
"""Raised when the reader's bounded SQLite busy budget expires."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TrustedLiveCancelledError(InterruptedError, TrustedLiveReaderError):
|
|
131
|
+
"""Raised when an external event or :meth:`interrupt` cancels an operation."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TrustedLiveDeadlineExceededError(TimeoutError, TrustedLiveReaderError):
|
|
135
|
+
"""Raised when a finite reader operation reaches its monotonic deadline."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TrustedLiveInvalidDatabaseError(TrustedLiveReaderError):
|
|
139
|
+
"""Raised for a corrupt, malformed, or non-SQLite database."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TrustedLiveCleanupError(TrustedLiveReaderError):
|
|
143
|
+
"""Raised when rollback or handle release cannot be verified cleanly."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TrustedLiveReaderClosedError(TrustedLiveReaderError):
|
|
147
|
+
"""Raised when an operation targets a closed reader."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TrustedLiveReaderThreadError(TrustedLiveReaderError):
|
|
151
|
+
"""Raised when a reader is used from a thread other than its owner."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TrustedLiveTransactionError(TrustedLiveReaderError):
|
|
155
|
+
"""Raised when an operation is re-entered on the same reader."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _PreflightFileDescriptorCloseError(OSError):
|
|
159
|
+
"""An internal signal that preflight descriptor cleanup is uncertain."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class TrustedLiveSourceIdentity:
|
|
164
|
+
"""Main binding plus source sidecars observed before the native open."""
|
|
165
|
+
|
|
166
|
+
database_instance: DatabaseInstance
|
|
167
|
+
wal_identity: DatabaseFileIdentity | None
|
|
168
|
+
shm_identity: DatabaseFileIdentity | None
|
|
169
|
+
journal_identity: DatabaseFileIdentity | None
|
|
170
|
+
journal_mode: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class TrustedLiveReaderToken:
|
|
175
|
+
"""Opaque incarnation token used to reject stale Stage 2 callbacks."""
|
|
176
|
+
|
|
177
|
+
source: TrustedLiveSourceIdentity
|
|
178
|
+
nonce: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True, slots=True)
|
|
182
|
+
class TrustedQuery:
|
|
183
|
+
"""One immutable statement specification for a finite reader operation.
|
|
184
|
+
|
|
185
|
+
Sequence and mapping bindings are shallow-copied when the specification is
|
|
186
|
+
made. A caller therefore cannot replace bindings while a batch transaction
|
|
187
|
+
is running.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
sql: str
|
|
191
|
+
bindings: SqliteBindings = None
|
|
192
|
+
|
|
193
|
+
def __post_init__(self) -> None:
|
|
194
|
+
bindings = self.bindings
|
|
195
|
+
if isinstance(bindings, Mapping):
|
|
196
|
+
frozen_bindings: SqliteBindings = MappingProxyType(dict(bindings))
|
|
197
|
+
elif bindings is None:
|
|
198
|
+
frozen_bindings = None
|
|
199
|
+
elif isinstance(bindings, bytearray):
|
|
200
|
+
frozen_bindings = (bytes(bindings),)
|
|
201
|
+
elif isinstance(bindings, (str, bytes)):
|
|
202
|
+
frozen_bindings = (bindings,)
|
|
203
|
+
else:
|
|
204
|
+
frozen_bindings = tuple(bindings)
|
|
205
|
+
object.__setattr__(self, "bindings", frozen_bindings)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class TrustedQueryResult:
|
|
210
|
+
"""One fully materialised result with no cursor or connection escape."""
|
|
211
|
+
|
|
212
|
+
columns: tuple[str, ...]
|
|
213
|
+
rows: tuple[tuple[Any, ...], ...]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class TrustedVfsAudit:
|
|
218
|
+
"""Native VFS counters attributed only to this reader incarnation."""
|
|
219
|
+
|
|
220
|
+
counters: Mapping[str, int]
|
|
221
|
+
|
|
222
|
+
def __getitem__(self, name: str) -> int:
|
|
223
|
+
return self.counters[name]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True, slots=True)
|
|
227
|
+
class _TrustedNativeStatus:
|
|
228
|
+
sequence: int
|
|
229
|
+
kind: str
|
|
230
|
+
artifact: str
|
|
231
|
+
operation: str
|
|
232
|
+
sqlite_code: int
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(slots=True)
|
|
236
|
+
class _TrustedOperationControl:
|
|
237
|
+
generation: int
|
|
238
|
+
started_at: float
|
|
239
|
+
deadline: float
|
|
240
|
+
busy_deadline: float
|
|
241
|
+
cancel_event: threading.Event | None
|
|
242
|
+
native_sequence: int = 0
|
|
243
|
+
abort_reason: str | None = None
|
|
244
|
+
policy_denied: bool = False
|
|
245
|
+
operation_result_length_limit: int | None = None
|
|
246
|
+
result_length_limit: int | None = None
|
|
247
|
+
blob_cleanup_error: BaseException | None = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True, slots=True)
|
|
251
|
+
class _TrustedSqliteLengthLimit:
|
|
252
|
+
"""One temporary, verified SQLite length-limit installation."""
|
|
253
|
+
|
|
254
|
+
previous: int
|
|
255
|
+
effective: int
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _trusted_statement_length_limit(
|
|
259
|
+
operation_baseline: int,
|
|
260
|
+
column_count: int,
|
|
261
|
+
) -> int:
|
|
262
|
+
"""Return the per-value cap for the raw and logical Python-row envelopes."""
|
|
263
|
+
|
|
264
|
+
width = max(1, column_count)
|
|
265
|
+
raw_payload_limit = TRUSTED_LIVE_MAX_TRANSIENT_RAW_ROW_BYTES // width
|
|
266
|
+
python_payload_room = (
|
|
267
|
+
TRUSTED_LIVE_MAX_TRANSIENT_PYTHON_ROW_BYTES
|
|
268
|
+
- _TRUSTED_LIVE_PYTHON_ROW_FIXED_BYTES
|
|
269
|
+
- (_TRUSTED_LIVE_PYTHON_FIXED_BYTES_PER_COLUMN * width)
|
|
270
|
+
)
|
|
271
|
+
if python_payload_room <= 0:
|
|
272
|
+
raise TrustedLiveResultLimitError(
|
|
273
|
+
"The trusted query result is too wide for the logical Python-row "
|
|
274
|
+
"materialisation budget."
|
|
275
|
+
)
|
|
276
|
+
python_payload_limit = python_payload_room // (
|
|
277
|
+
_TRUSTED_LIVE_PYTHON_TEXT_EXPANSION * width
|
|
278
|
+
)
|
|
279
|
+
effective = min(
|
|
280
|
+
operation_baseline,
|
|
281
|
+
TRUSTED_LIVE_MAX_SCALAR_BYTES,
|
|
282
|
+
raw_payload_limit,
|
|
283
|
+
python_payload_limit,
|
|
284
|
+
)
|
|
285
|
+
if effective < 1:
|
|
286
|
+
raise TrustedLiveResultLimitError(
|
|
287
|
+
"The trusted query result is too wide for a positive SQLite "
|
|
288
|
+
"result-length limit."
|
|
289
|
+
)
|
|
290
|
+
return effective
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(slots=True)
|
|
294
|
+
class _TrustedResultBudget:
|
|
295
|
+
"""Incrementally bound one batch before retaining each SQLite row."""
|
|
296
|
+
|
|
297
|
+
maximum_wire_bytes: int
|
|
298
|
+
used_wire_bytes: int
|
|
299
|
+
total_cells: int = 0
|
|
300
|
+
abort_check: Callable[[], None] | None = None
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def for_query_batch(cls) -> _TrustedResultBudget:
|
|
304
|
+
budget = cls(
|
|
305
|
+
maximum_wire_bytes=TRUSTED_LIVE_MAX_REPLY_BYTES,
|
|
306
|
+
used_wire_bytes=0,
|
|
307
|
+
)
|
|
308
|
+
budget.consume(
|
|
309
|
+
_RESULT_FRAME_ENVELOPE_RESERVE_BYTES,
|
|
310
|
+
"The protocol envelope",
|
|
311
|
+
)
|
|
312
|
+
budget.consume(
|
|
313
|
+
_RESULT_WIRE_CONTAINER_BUDGET_BYTES,
|
|
314
|
+
"Query results",
|
|
315
|
+
)
|
|
316
|
+
return budget
|
|
317
|
+
|
|
318
|
+
def consume(self, amount: int, description: str) -> None:
|
|
319
|
+
if amount < 0 or amount > self.maximum_wire_bytes - self.used_wire_bytes:
|
|
320
|
+
raise TrustedLiveResultLimitError(
|
|
321
|
+
f"{description} exceeds the aggregate "
|
|
322
|
+
f"{self.maximum_wire_bytes}-byte result wire budget."
|
|
323
|
+
)
|
|
324
|
+
self.used_wire_bytes += amount
|
|
325
|
+
|
|
326
|
+
def start_result(self, description: Sequence[Sequence[Any]]) -> tuple[str, ...]:
|
|
327
|
+
if len(description) > TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT:
|
|
328
|
+
raise TrustedLiveResultLimitError(
|
|
329
|
+
"A trusted query result has more than "
|
|
330
|
+
f"{TRUSTED_LIVE_MAX_COLUMNS_PER_RESULT} columns."
|
|
331
|
+
)
|
|
332
|
+
self.consume(_RESULT_WIRE_CONTAINER_BUDGET_BYTES, "Query results")
|
|
333
|
+
columns: list[str] = []
|
|
334
|
+
for column_description in description:
|
|
335
|
+
if self.abort_check is not None:
|
|
336
|
+
self.abort_check()
|
|
337
|
+
if not column_description or not isinstance(column_description[0], str):
|
|
338
|
+
raise TrustedLiveQueryError(
|
|
339
|
+
"SQLite returned a result column without a valid text name."
|
|
340
|
+
)
|
|
341
|
+
column = column_description[0]
|
|
342
|
+
_utf8_bytes, json_bytes = _trusted_text_sizes(
|
|
343
|
+
column,
|
|
344
|
+
description="A trusted query result column name",
|
|
345
|
+
maximum_utf8_bytes=TRUSTED_LIVE_MAX_COLUMN_NAME_BYTES,
|
|
346
|
+
abort_check=self.abort_check,
|
|
347
|
+
)
|
|
348
|
+
self.consume(
|
|
349
|
+
_RESULT_WIRE_FIELD_BUDGET_BYTES + json_bytes,
|
|
350
|
+
"Query result columns",
|
|
351
|
+
)
|
|
352
|
+
columns.append(column)
|
|
353
|
+
return tuple(columns)
|
|
354
|
+
|
|
355
|
+
def retain_row(
|
|
356
|
+
self,
|
|
357
|
+
row: Sequence[Any],
|
|
358
|
+
*,
|
|
359
|
+
column_count: int,
|
|
360
|
+
retained_row_count: int,
|
|
361
|
+
) -> tuple[Any, ...]:
|
|
362
|
+
if retained_row_count >= TRUSTED_LIVE_MAX_ROWS_PER_RESULT:
|
|
363
|
+
raise TrustedLiveResultLimitError(
|
|
364
|
+
"A trusted query result has more than "
|
|
365
|
+
f"{TRUSTED_LIVE_MAX_ROWS_PER_RESULT} rows."
|
|
366
|
+
)
|
|
367
|
+
if len(row) != column_count:
|
|
368
|
+
raise TrustedLiveQueryError(
|
|
369
|
+
"SQLite returned a result row with the wrong number of columns."
|
|
370
|
+
)
|
|
371
|
+
if column_count > TRUSTED_LIVE_MAX_CELLS_PER_REPLY - self.total_cells:
|
|
372
|
+
raise TrustedLiveResultLimitError(
|
|
373
|
+
"A trusted query batch has more than "
|
|
374
|
+
f"{TRUSTED_LIVE_MAX_CELLS_PER_REPLY} result cells."
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
self.consume(
|
|
378
|
+
_RESULT_WIRE_CONTAINER_BUDGET_BYTES
|
|
379
|
+
+ (_RESULT_WIRE_FIELD_BUDGET_BYTES * column_count),
|
|
380
|
+
"Query result rows",
|
|
381
|
+
)
|
|
382
|
+
retained_values: list[Any] = []
|
|
383
|
+
for value in row:
|
|
384
|
+
if self.abort_check is not None:
|
|
385
|
+
self.abort_check()
|
|
386
|
+
scalar, wire_bytes = _trusted_result_scalar(
|
|
387
|
+
value,
|
|
388
|
+
abort_check=self.abort_check,
|
|
389
|
+
)
|
|
390
|
+
self.consume(wire_bytes, "Query result values")
|
|
391
|
+
retained_values.append(scalar)
|
|
392
|
+
self.total_cells += column_count
|
|
393
|
+
return tuple(retained_values)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _trusted_text_sizes(
|
|
397
|
+
value: str,
|
|
398
|
+
*,
|
|
399
|
+
description: str,
|
|
400
|
+
maximum_utf8_bytes: int,
|
|
401
|
+
abort_check: Callable[[], None] | None = None,
|
|
402
|
+
) -> tuple[int, int]:
|
|
403
|
+
"""Size UTF-8 and canonical JSON text without allocating either form."""
|
|
404
|
+
|
|
405
|
+
utf8_bytes = 0
|
|
406
|
+
json_bytes = 2 # Opening and closing JSON quotes.
|
|
407
|
+
for character_index, character in enumerate(value):
|
|
408
|
+
if character_index % 4_096 == 0 and abort_check is not None:
|
|
409
|
+
abort_check()
|
|
410
|
+
codepoint = ord(character)
|
|
411
|
+
if 0xD800 <= codepoint <= 0xDFFF:
|
|
412
|
+
raise TrustedLiveQueryError(
|
|
413
|
+
f"{description} contains text that is not valid Unicode."
|
|
414
|
+
)
|
|
415
|
+
if codepoint <= 0x7F:
|
|
416
|
+
character_bytes = 1
|
|
417
|
+
elif codepoint <= 0x7FF:
|
|
418
|
+
character_bytes = 2
|
|
419
|
+
elif codepoint <= 0xFFFF:
|
|
420
|
+
character_bytes = 3
|
|
421
|
+
else:
|
|
422
|
+
character_bytes = 4
|
|
423
|
+
utf8_bytes += character_bytes
|
|
424
|
+
if utf8_bytes > maximum_utf8_bytes:
|
|
425
|
+
raise TrustedLiveResultLimitError(
|
|
426
|
+
f"{description} exceeds the {maximum_utf8_bytes}-byte limit."
|
|
427
|
+
)
|
|
428
|
+
if character in {'"', "\\"}:
|
|
429
|
+
json_bytes += 2
|
|
430
|
+
elif codepoint <= 0x1F:
|
|
431
|
+
json_bytes += 2 if character in "\b\t\n\f\r" else 6
|
|
432
|
+
else:
|
|
433
|
+
json_bytes += character_bytes
|
|
434
|
+
return utf8_bytes, json_bytes
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _trusted_result_scalar(
|
|
438
|
+
value: Any,
|
|
439
|
+
*,
|
|
440
|
+
abort_check: Callable[[], None] | None = None,
|
|
441
|
+
) -> tuple[Any, int]:
|
|
442
|
+
"""Validate, normalise, and conservatively size one live SQLite scalar."""
|
|
443
|
+
|
|
444
|
+
if value is None:
|
|
445
|
+
return None, 8
|
|
446
|
+
if isinstance(value, bool):
|
|
447
|
+
return value, 16
|
|
448
|
+
if isinstance(value, int):
|
|
449
|
+
if value < _SQLITE_INTEGER_MIN or value > _SQLITE_INTEGER_MAX:
|
|
450
|
+
raise TrustedLiveQueryError(
|
|
451
|
+
"SQLite returned an integer outside its signed 64-bit range."
|
|
452
|
+
)
|
|
453
|
+
return value, 16 + len(str(value))
|
|
454
|
+
if isinstance(value, float):
|
|
455
|
+
# SQLite can deliberately return infinities. They are represented by
|
|
456
|
+
# a canonical tagged hexadecimal string at the IPC layer rather than
|
|
457
|
+
# as a non-finite JSON number.
|
|
458
|
+
return value, 16 + len(value.hex())
|
|
459
|
+
if isinstance(value, str):
|
|
460
|
+
_utf8_bytes, json_bytes = _trusted_text_sizes(
|
|
461
|
+
value,
|
|
462
|
+
description="A SQLite text result",
|
|
463
|
+
maximum_utf8_bytes=TRUSTED_LIVE_MAX_SCALAR_BYTES,
|
|
464
|
+
abort_check=abort_check,
|
|
465
|
+
)
|
|
466
|
+
return value, 16 + json_bytes
|
|
467
|
+
if isinstance(value, memoryview):
|
|
468
|
+
blob_size = value.nbytes
|
|
469
|
+
elif isinstance(value, (bytes, bytearray)):
|
|
470
|
+
blob_size = len(value)
|
|
471
|
+
else:
|
|
472
|
+
raise TrustedLiveQueryError(
|
|
473
|
+
"SQLite returned a value outside its null, integer, real, text, "
|
|
474
|
+
"and blob scalar types."
|
|
475
|
+
)
|
|
476
|
+
if blob_size > TRUSTED_LIVE_MAX_SCALAR_BYTES:
|
|
477
|
+
raise TrustedLiveResultLimitError(
|
|
478
|
+
"A SQLite blob result exceeds the "
|
|
479
|
+
f"{TRUSTED_LIVE_MAX_SCALAR_BYTES}-byte limit."
|
|
480
|
+
)
|
|
481
|
+
blob = value if isinstance(value, bytes) else bytes(value)
|
|
482
|
+
base64_bytes = 4 * ((blob_size + 2) // 3)
|
|
483
|
+
return blob, 16 + base64_bytes
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def preflight_trusted_query_results(
|
|
487
|
+
results: Sequence[TrustedQueryResult],
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Apply the live reader's exact aggregate result budget to existing rows.
|
|
490
|
+
|
|
491
|
+
The helper protocol calls this as a defensive second check. Normal live
|
|
492
|
+
queries use the same budget incrementally, before each row is retained.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
if isinstance(results, (str, bytes, bytearray, memoryview)):
|
|
496
|
+
raise TrustedLiveQueryError("Trusted query results must be a sequence.")
|
|
497
|
+
result_count = len(results)
|
|
498
|
+
if not 1 <= result_count <= TRUSTED_LIVE_MAX_BATCH_QUERIES:
|
|
499
|
+
raise TrustedLiveResultLimitError(
|
|
500
|
+
"A trusted query batch must contain between 1 and "
|
|
501
|
+
f"{TRUSTED_LIVE_MAX_BATCH_QUERIES} results."
|
|
502
|
+
)
|
|
503
|
+
budget = _TrustedResultBudget.for_query_batch()
|
|
504
|
+
for index in range(result_count):
|
|
505
|
+
result = results[index]
|
|
506
|
+
if not isinstance(result, TrustedQueryResult):
|
|
507
|
+
raise TrustedLiveQueryError(
|
|
508
|
+
"A trusted query batch contains a non-result object."
|
|
509
|
+
)
|
|
510
|
+
description = tuple((column,) for column in result.columns)
|
|
511
|
+
columns = budget.start_result(description)
|
|
512
|
+
if columns != result.columns:
|
|
513
|
+
raise TrustedLiveQueryError(
|
|
514
|
+
"A trusted query result has invalid column metadata."
|
|
515
|
+
)
|
|
516
|
+
for row_index, row in enumerate(result.rows):
|
|
517
|
+
budget.retain_row(
|
|
518
|
+
row,
|
|
519
|
+
column_count=len(columns),
|
|
520
|
+
retained_row_count=row_index,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _claim_process_session(owner: object) -> None:
|
|
525
|
+
global _PROCESS_SESSION_OWNER
|
|
526
|
+
with _PROCESS_SESSION_MUTEX:
|
|
527
|
+
if _PROCESS_SESSION_QUARANTINE_REASON is not None:
|
|
528
|
+
raise TrustedLiveReaderUnavailableError(
|
|
529
|
+
"A prior trusted-reader cleanup failure quarantined this process. "
|
|
530
|
+
"Terminate the process before opening another trusted reader. "
|
|
531
|
+
"Recorded cleanup uncertainty: "
|
|
532
|
+
f"{_PROCESS_SESSION_QUARANTINE_REASON}"
|
|
533
|
+
)
|
|
534
|
+
if _PROCESS_SESSION_OWNER is not None:
|
|
535
|
+
raise TrustedLiveReaderUnavailableError(
|
|
536
|
+
"Close the existing trusted reader in this process before "
|
|
537
|
+
"opening another one."
|
|
538
|
+
)
|
|
539
|
+
_PROCESS_SESSION_OWNER = owner
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _quarantine_process_session(owner: object, error: BaseException) -> None:
|
|
543
|
+
global _PROCESS_SESSION_QUARANTINE_REASON
|
|
544
|
+
with _PROCESS_SESSION_MUTEX:
|
|
545
|
+
if _PROCESS_SESSION_OWNER is owner:
|
|
546
|
+
_PROCESS_SESSION_QUARANTINE_REASON = f"{type(error).__name__}: {error}"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _release_process_session(owner: object) -> None:
|
|
550
|
+
global _PROCESS_SESSION_OWNER, _PROCESS_SESSION_QUARANTINE_REASON
|
|
551
|
+
with _PROCESS_SESSION_MUTEX:
|
|
552
|
+
# Identity ownership makes constructor/close cleanup idempotent and
|
|
553
|
+
# prevents one interrupted reader from clearing another reader's claim.
|
|
554
|
+
if _PROCESS_SESSION_OWNER is owner:
|
|
555
|
+
_PROCESS_SESSION_OWNER = None
|
|
556
|
+
_PROCESS_SESSION_QUARANTINE_REASON = None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _native_extension_path() -> Path:
|
|
560
|
+
try:
|
|
561
|
+
native_version = version("qplotter-native")
|
|
562
|
+
except PackageNotFoundError as error:
|
|
563
|
+
raise TrustedLiveReaderUnavailableError(
|
|
564
|
+
"qPlot requires qplotter-native=="
|
|
565
|
+
f"{TRUSTED_READER_NATIVE_VERSION}; install its platform wheel."
|
|
566
|
+
) from error
|
|
567
|
+
if native_version != TRUSTED_READER_NATIVE_VERSION:
|
|
568
|
+
raise TrustedLiveReaderUnavailableError(
|
|
569
|
+
"qPlot requires qplotter-native=="
|
|
570
|
+
f"{TRUSTED_READER_NATIVE_VERSION}; found {native_version}."
|
|
571
|
+
)
|
|
572
|
+
try:
|
|
573
|
+
module = importlib.import_module("qplot_native._trusted_vfs_native")
|
|
574
|
+
except ImportError as error:
|
|
575
|
+
raise TrustedLiveReaderUnavailableError(
|
|
576
|
+
"qPlot's trusted live-reader native VFS is not installed."
|
|
577
|
+
) from error
|
|
578
|
+
if (
|
|
579
|
+
getattr(module, "sqlite_version", None) != TRUSTED_READER_SQLITE_VERSION
|
|
580
|
+
or getattr(module, "vfs_name", None) != TRUSTED_READER_VFS_NAME
|
|
581
|
+
):
|
|
582
|
+
raise TrustedLiveReaderUnavailableError(
|
|
583
|
+
"qPlot's native VFS is incompatible with the pinned trusted reader."
|
|
584
|
+
)
|
|
585
|
+
module_file = getattr(module, "__file__", None)
|
|
586
|
+
if not module_file:
|
|
587
|
+
raise TrustedLiveReaderUnavailableError(
|
|
588
|
+
"qPlot could not locate its trusted live-reader native VFS."
|
|
589
|
+
)
|
|
590
|
+
return Path(module_file).resolve()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _check_pinned_sqlite_runtime() -> None:
|
|
594
|
+
apsw_version = apsw.apsw_version()
|
|
595
|
+
sqlite_version = apsw.sqlite_lib_version()
|
|
596
|
+
sqlite_source_id = apsw.sqlite3_sourceid()
|
|
597
|
+
if (
|
|
598
|
+
apsw_version != TRUSTED_READER_APSW_VERSION
|
|
599
|
+
or sqlite_version != TRUSTED_READER_SQLITE_VERSION
|
|
600
|
+
or sqlite_source_id != TRUSTED_READER_SQLITE_SOURCE_ID
|
|
601
|
+
):
|
|
602
|
+
raise TrustedLiveReaderUnavailableError(
|
|
603
|
+
"The trusted live reader requires APSW "
|
|
604
|
+
f"{TRUSTED_READER_APSW_VERSION} with SQLite "
|
|
605
|
+
f"{TRUSTED_READER_SQLITE_VERSION}; found APSW {apsw_version} "
|
|
606
|
+
f"with SQLite {sqlite_version} ({sqlite_source_id}). qPlot did "
|
|
607
|
+
"not open the source."
|
|
608
|
+
)
|
|
609
|
+
get_effective_user_id = getattr(os, "geteuid", None)
|
|
610
|
+
if get_effective_user_id is not None and get_effective_user_id() == 0:
|
|
611
|
+
raise TrustedLiveReaderUnavailableError(
|
|
612
|
+
"The trusted live reader refuses to run as the POSIX root user. "
|
|
613
|
+
"SQLite's Unix VFS may issue fchown() while opening a WAL "
|
|
614
|
+
"shared-memory file as root."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _reject_symlink_path_components(path: Path) -> None:
|
|
619
|
+
"""Reject a selected path containing any currently visible symlink.
|
|
620
|
+
|
|
621
|
+
This records only the observed trusted hierarchy at preflight. The native
|
|
622
|
+
VFS separately compares expected identities with SQLite's actual handles;
|
|
623
|
+
neither check claims to defend a hostile parent namespace.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
absolute_path = Path(os.path.abspath(os.fspath(path)))
|
|
627
|
+
current = Path(absolute_path.anchor)
|
|
628
|
+
components = (
|
|
629
|
+
absolute_path.parts[1:] if absolute_path.anchor else absolute_path.parts
|
|
630
|
+
)
|
|
631
|
+
for index, component in enumerate(components):
|
|
632
|
+
current /= component
|
|
633
|
+
try:
|
|
634
|
+
status = current.lstat()
|
|
635
|
+
except FileNotFoundError:
|
|
636
|
+
if index == len(components) - 1:
|
|
637
|
+
return
|
|
638
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
639
|
+
f"A parent of the selected trusted database does not exist: {current}"
|
|
640
|
+
) from None
|
|
641
|
+
except OSError as error:
|
|
642
|
+
raise TrustedLiveSourceIOError(
|
|
643
|
+
f"Could not inspect a selected database path component "
|
|
644
|
+
f"{current}: {error}"
|
|
645
|
+
) from error
|
|
646
|
+
file_attributes = int(getattr(status, "st_file_attributes", 0))
|
|
647
|
+
reparse_point = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))
|
|
648
|
+
if stat.S_ISLNK(status.st_mode) or (
|
|
649
|
+
os.name == "nt" and file_attributes & reparse_point
|
|
650
|
+
):
|
|
651
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
652
|
+
"Trusted live reading does not accept symbolic-link or "
|
|
653
|
+
f"reparse-point path components: {current}"
|
|
654
|
+
)
|
|
655
|
+
if index != len(components) - 1 and not stat.S_ISDIR(status.st_mode):
|
|
656
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
657
|
+
f"A parent of the selected trusted database is not a directory: {current}"
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _reject_windows_alternate_data_stream(path: Path) -> None:
|
|
662
|
+
"""Reject NTFS stream syntax before opening any source-family handle."""
|
|
663
|
+
|
|
664
|
+
if os.name != "nt":
|
|
665
|
+
return
|
|
666
|
+
_drive, tail = os.path.splitdrive(os.fspath(path))
|
|
667
|
+
if ":" in tail:
|
|
668
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
669
|
+
"Trusted live reading does not accept Windows alternate data "
|
|
670
|
+
f"stream paths: {path}"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _require_regular_file(path: Path, description: str) -> os.stat_result:
|
|
675
|
+
try:
|
|
676
|
+
status = path.lstat()
|
|
677
|
+
except FileNotFoundError:
|
|
678
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
679
|
+
f"The trusted database {description} does not exist: {path}"
|
|
680
|
+
) from None
|
|
681
|
+
except OSError as error:
|
|
682
|
+
raise TrustedLiveSourceIOError(
|
|
683
|
+
f"Could not inspect the trusted database {description} {path}: {error}"
|
|
684
|
+
) from error
|
|
685
|
+
file_attributes = int(getattr(status, "st_file_attributes", 0))
|
|
686
|
+
reparse_point = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))
|
|
687
|
+
if stat.S_ISLNK(status.st_mode) or (
|
|
688
|
+
os.name == "nt" and file_attributes & reparse_point
|
|
689
|
+
):
|
|
690
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
691
|
+
f"The trusted database {description} must not be a symbolic link "
|
|
692
|
+
f"or reparse point: {path}"
|
|
693
|
+
)
|
|
694
|
+
if not stat.S_ISREG(status.st_mode):
|
|
695
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
696
|
+
f"The trusted database {description} is not a regular file: {path}"
|
|
697
|
+
)
|
|
698
|
+
return status
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _required_file_identity(
|
|
702
|
+
path: Path,
|
|
703
|
+
description: str,
|
|
704
|
+
) -> DatabaseFileIdentity:
|
|
705
|
+
_require_regular_file(path, description)
|
|
706
|
+
try:
|
|
707
|
+
identity = checked_path_bound_file_identity(path)
|
|
708
|
+
except FileIdentityHandleCloseError:
|
|
709
|
+
# The constructor handles this separately: an uncertain Windows HANDLE
|
|
710
|
+
# close must quarantine the process session, not look like ordinary I/O.
|
|
711
|
+
raise
|
|
712
|
+
except OSError as error:
|
|
713
|
+
raise TrustedLiveSourceIOError(
|
|
714
|
+
f"Could not identify the trusted database {description} {path}: {error}"
|
|
715
|
+
) from error
|
|
716
|
+
if identity is None:
|
|
717
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
718
|
+
f"Could not establish a stable identity for the trusted database "
|
|
719
|
+
f"{description} {path}."
|
|
720
|
+
)
|
|
721
|
+
return identity
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _optional_file_identity(
|
|
725
|
+
path: Path,
|
|
726
|
+
description: str,
|
|
727
|
+
) -> DatabaseFileIdentity | None:
|
|
728
|
+
try:
|
|
729
|
+
path.lstat()
|
|
730
|
+
except FileNotFoundError:
|
|
731
|
+
return None
|
|
732
|
+
except OSError as error:
|
|
733
|
+
raise TrustedLiveSourceIOError(
|
|
734
|
+
f"Could not inspect the trusted database {description} {path}: {error}"
|
|
735
|
+
) from error
|
|
736
|
+
return _required_file_identity(path, description)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _capture_database_instance_for_trusted_open(
|
|
740
|
+
database_path: str | os.PathLike[str],
|
|
741
|
+
) -> tuple[DatabaseInstance, DatabaseFileIdentity]:
|
|
742
|
+
"""Capture UI-comparable and native identities without unchecked handles."""
|
|
743
|
+
|
|
744
|
+
selected_path = Path(os.path.abspath(os.fspath(database_path)))
|
|
745
|
+
_reject_windows_alternate_data_stream(selected_path)
|
|
746
|
+
_reject_symlink_path_components(selected_path)
|
|
747
|
+
logical_path = logical_database_path(database_path)
|
|
748
|
+
resolved_path = canonical_database_path(logical_path)
|
|
749
|
+
status = _require_regular_file(Path(resolved_path), "main file")
|
|
750
|
+
native_identity = _required_file_identity(Path(resolved_path), "main file")
|
|
751
|
+
|
|
752
|
+
inode = int(getattr(status, "st_ino", 0) or 0)
|
|
753
|
+
if inode:
|
|
754
|
+
comparable_identity: DatabaseFileIdentity = (
|
|
755
|
+
int(getattr(status, "st_dev", 0) or 0),
|
|
756
|
+
inode,
|
|
757
|
+
)
|
|
758
|
+
else:
|
|
759
|
+
# On Windows the ordinary UI identity helper falls back to the same
|
|
760
|
+
# volume/file-index identity. Using the checked observation here keeps
|
|
761
|
+
# expected-instance comparisons compatible without an unchecked HANDLE.
|
|
762
|
+
comparable_identity = native_identity
|
|
763
|
+
return (
|
|
764
|
+
DatabaseInstance(
|
|
765
|
+
logical_path=logical_path,
|
|
766
|
+
resolved_path=resolved_path,
|
|
767
|
+
identity=comparable_identity,
|
|
768
|
+
),
|
|
769
|
+
native_identity,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _database_header_journal_mode(database_path: Path) -> str:
|
|
774
|
+
open_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
775
|
+
open_flags |= getattr(os, "O_CLOEXEC", 0)
|
|
776
|
+
open_flags |= getattr(os, "O_NOINHERIT", 0)
|
|
777
|
+
open_flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
778
|
+
try:
|
|
779
|
+
file_descriptor = os.open(database_path, open_flags)
|
|
780
|
+
try:
|
|
781
|
+
header_parts: list[bytes] = []
|
|
782
|
+
remaining = _SQLITE_HEADER_PREFIX_BYTES
|
|
783
|
+
while remaining:
|
|
784
|
+
part = os.read(file_descriptor, remaining)
|
|
785
|
+
if not part:
|
|
786
|
+
break
|
|
787
|
+
header_parts.append(part)
|
|
788
|
+
remaining -= len(part)
|
|
789
|
+
header = b"".join(header_parts)
|
|
790
|
+
finally:
|
|
791
|
+
_close_preflight_file_descriptor(file_descriptor)
|
|
792
|
+
except _PreflightFileDescriptorCloseError:
|
|
793
|
+
raise
|
|
794
|
+
except OSError as error:
|
|
795
|
+
raise TrustedLiveSourceIOError(
|
|
796
|
+
f"Could not read the SQLite header of {database_path}: {error}"
|
|
797
|
+
) from error
|
|
798
|
+
if len(header) < _SQLITE_HEADER_PREFIX_BYTES or not header.startswith(
|
|
799
|
+
b"SQLite format 3\x00"
|
|
800
|
+
):
|
|
801
|
+
raise TrustedLiveInvalidDatabaseError(
|
|
802
|
+
f"The selected file is not a complete SQLite 3 database: {database_path}"
|
|
803
|
+
)
|
|
804
|
+
write_version = header[_SQLITE_HEADER_WRITE_VERSION_OFFSET]
|
|
805
|
+
read_version = header[_SQLITE_HEADER_READ_VERSION_OFFSET]
|
|
806
|
+
if (write_version, read_version) == (
|
|
807
|
+
_SQLITE_WAL_FORMAT,
|
|
808
|
+
_SQLITE_WAL_FORMAT,
|
|
809
|
+
):
|
|
810
|
+
return "wal"
|
|
811
|
+
if (write_version, read_version) == (
|
|
812
|
+
_SQLITE_ROLLBACK_FORMAT,
|
|
813
|
+
_SQLITE_ROLLBACK_FORMAT,
|
|
814
|
+
):
|
|
815
|
+
return "rollback"
|
|
816
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
817
|
+
"The selected database uses unsupported SQLite header read/write "
|
|
818
|
+
f"versions {read_version}/{write_version}."
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _close_preflight_file_descriptor(file_descriptor: int) -> None:
|
|
823
|
+
"""Close one preflight fd exactly once and surface uncertain cleanup."""
|
|
824
|
+
|
|
825
|
+
try:
|
|
826
|
+
os.close(file_descriptor)
|
|
827
|
+
except OSError as error:
|
|
828
|
+
# In particular, never retry close(2) after EINTR: supported kernels
|
|
829
|
+
# may already have consumed and reassigned the descriptor number.
|
|
830
|
+
raise _PreflightFileDescriptorCloseError(*error.args) from error
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _capture_source_identity(
|
|
834
|
+
database_path: str | os.PathLike[str],
|
|
835
|
+
) -> tuple[TrustedLiveSourceIdentity, DatabaseFileIdentity]:
|
|
836
|
+
instance, main_identity = _capture_database_instance_for_trusted_open(database_path)
|
|
837
|
+
resolved = Path(instance.resolved_path)
|
|
838
|
+
# This is the sole source-content read before SQLite opens the database.
|
|
839
|
+
# All validation after xOpen goes through the token-gated native proof.
|
|
840
|
+
journal_mode = _database_header_journal_mode(resolved)
|
|
841
|
+
wal_path = Path(f"{resolved}-wal")
|
|
842
|
+
shm_path = Path(f"{resolved}-shm")
|
|
843
|
+
journal_path = Path(f"{resolved}-journal")
|
|
844
|
+
wal_identity = _optional_file_identity(wal_path, "WAL file")
|
|
845
|
+
shm_identity = _optional_file_identity(shm_path, "SHM file")
|
|
846
|
+
journal_identity = _optional_file_identity(journal_path, "rollback journal")
|
|
847
|
+
instance = DatabaseInstance(
|
|
848
|
+
logical_path=instance.logical_path,
|
|
849
|
+
resolved_path=instance.resolved_path,
|
|
850
|
+
identity=instance.identity,
|
|
851
|
+
# Keep this UI-facing instance comparable with database_instance().
|
|
852
|
+
# On Windows, the checked native identities above deliberately use
|
|
853
|
+
# volume/file-index tuples while ordinary UI observations prefer the
|
|
854
|
+
# non-zero device/inode values exposed by Python. The native values
|
|
855
|
+
# remain on TrustedLiveSourceIdentity for the VFS proof below.
|
|
856
|
+
sidecar_identities=database_sidecar_identities(
|
|
857
|
+
instance.logical_path,
|
|
858
|
+
instance.resolved_path,
|
|
859
|
+
),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
if journal_mode == "wal":
|
|
863
|
+
if journal_identity is not None:
|
|
864
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
865
|
+
"The trusted WAL database also has a rollback journal; the "
|
|
866
|
+
"ambiguous source family was rejected."
|
|
867
|
+
)
|
|
868
|
+
else:
|
|
869
|
+
if any(
|
|
870
|
+
identity is not None
|
|
871
|
+
for identity in (wal_identity, shm_identity, journal_identity)
|
|
872
|
+
):
|
|
873
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
874
|
+
"Direct rollback-mode reads require a sidecar-free database. "
|
|
875
|
+
"Use qPlot's existing private-snapshot path for retained or "
|
|
876
|
+
"hot journals."
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
return (
|
|
880
|
+
TrustedLiveSourceIdentity(
|
|
881
|
+
database_instance=instance,
|
|
882
|
+
wal_identity=wal_identity,
|
|
883
|
+
shm_identity=shm_identity,
|
|
884
|
+
journal_identity=journal_identity,
|
|
885
|
+
journal_mode=journal_mode,
|
|
886
|
+
),
|
|
887
|
+
main_identity,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _native_expected_identity_parameters(
|
|
892
|
+
parameter_prefix: str,
|
|
893
|
+
identity: DatabaseFileIdentity | None,
|
|
894
|
+
*,
|
|
895
|
+
allow_absent: bool,
|
|
896
|
+
) -> str:
|
|
897
|
+
"""Encode one expected source identity for the pinned native VFS."""
|
|
898
|
+
|
|
899
|
+
if identity is None:
|
|
900
|
+
if not allow_absent:
|
|
901
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
902
|
+
"The selected database has no stable main-file identity."
|
|
903
|
+
)
|
|
904
|
+
return f"{parameter_prefix}_kind=absent"
|
|
905
|
+
|
|
906
|
+
if len(identity) == 2 and type(identity[0]) is int and type(identity[1]) is int:
|
|
907
|
+
identity_kind = "posix"
|
|
908
|
+
identity_a, identity_b = identity
|
|
909
|
+
elif (
|
|
910
|
+
len(identity) == 3
|
|
911
|
+
and identity[0] == "windows-file-id"
|
|
912
|
+
and type(identity[1]) is int
|
|
913
|
+
and type(identity[2]) is int
|
|
914
|
+
):
|
|
915
|
+
identity_kind = "windows"
|
|
916
|
+
identity_a, identity_b = identity[1:]
|
|
917
|
+
else:
|
|
918
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
919
|
+
"The selected database filesystem does not expose an identity "
|
|
920
|
+
"supported by qPlot's native trusted reader."
|
|
921
|
+
)
|
|
922
|
+
if identity_a < 0 or identity_b <= 0:
|
|
923
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
924
|
+
"The selected database returned an invalid operating-system identity."
|
|
925
|
+
)
|
|
926
|
+
return (
|
|
927
|
+
f"{parameter_prefix}_kind={identity_kind}"
|
|
928
|
+
f"&{parameter_prefix}_a={identity_a:x}"
|
|
929
|
+
f"&{parameter_prefix}_b={identity_b:x}"
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _sqlite_uri_path(path: str) -> str:
|
|
934
|
+
# Preserve separators and the Windows drive colon; quote URI delimiters,
|
|
935
|
+
# whitespace, and non-ASCII bytes so native URI parameters stay distinct.
|
|
936
|
+
return quote(path, safe="/:\\")
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class TrustedLiveReader:
|
|
940
|
+
"""One owner-thread-bound connection offering only finite read jobs.
|
|
941
|
+
|
|
942
|
+
``query`` and ``query_batch`` own their complete transaction lifetime and
|
|
943
|
+
always materialise results before returning. Their default timeout is
|
|
944
|
+
finite. Callers may supply a shorter or longer timeout, an absolute
|
|
945
|
+
monotonic deadline, and a :class:`threading.Event` cancellation signal.
|
|
946
|
+
|
|
947
|
+
Python/SQLite progress and busy handlers provide cooperative in-process
|
|
948
|
+
interruption. :meth:`interrupt` additionally calls ``sqlite3_interrupt``
|
|
949
|
+
and is safe from a different thread. These mechanisms do not forcibly
|
|
950
|
+
pre-empt arbitrary stalled Python or operating-system code; the later
|
|
951
|
+
helper-process stage provides that hard failure boundary.
|
|
952
|
+
"""
|
|
953
|
+
|
|
954
|
+
def __init__(
|
|
955
|
+
self,
|
|
956
|
+
database_path: str | os.PathLike[str],
|
|
957
|
+
*,
|
|
958
|
+
expected_database_instance: DatabaseInstance | None = None,
|
|
959
|
+
busy_timeout_ms: int = 5_000,
|
|
960
|
+
operation_timeout_seconds: float = (DEFAULT_TRUSTED_OPERATION_TIMEOUT_SECONDS),
|
|
961
|
+
_test_race_artifact: str | None = None,
|
|
962
|
+
_test_cleanup_fault: str | None = None,
|
|
963
|
+
_test_statement_limit_fault: str | None = None,
|
|
964
|
+
_test_pre_open_callback: (
|
|
965
|
+
Callable[[TrustedLiveReaderToken, Path], None] | None
|
|
966
|
+
) = None,
|
|
967
|
+
) -> None:
|
|
968
|
+
self._owner_thread = threading.get_ident()
|
|
969
|
+
self._closed = False
|
|
970
|
+
self._invalidated = False
|
|
971
|
+
self._internal_transaction_control = False
|
|
972
|
+
self._connection: apsw.Connection | None = None
|
|
973
|
+
self._bootstrap: apsw.Connection | None = None
|
|
974
|
+
self._final_audit: TrustedVfsAudit | None = None
|
|
975
|
+
self._temporary_directory: tempfile.TemporaryDirectory[str] | None = None
|
|
976
|
+
self._native_temporary_path: Path | None = None
|
|
977
|
+
self._token_string = secrets.token_hex(16)
|
|
978
|
+
self._process_session_owner = object()
|
|
979
|
+
self._operation_mutex = threading.Lock()
|
|
980
|
+
self._active_operation: _TrustedOperationControl | None = None
|
|
981
|
+
self._next_operation_generation = 1
|
|
982
|
+
self._native_extension_loaded = False
|
|
983
|
+
self._native_session_attempted = False
|
|
984
|
+
self._native_release_confirmed = False
|
|
985
|
+
self._resource_cleanup_complete = False
|
|
986
|
+
self._resource_cleanup_error: TrustedLiveCleanupError | None = None
|
|
987
|
+
self._preflight_cleanup_error: OSError | None = None
|
|
988
|
+
# Arm the private statement-limit fault only after construction's
|
|
989
|
+
# bootstrap operations have completed.
|
|
990
|
+
self._test_statement_limit_fault: str | None = None
|
|
991
|
+
self._test_statement_limit_fault_consumed = False
|
|
992
|
+
self._default_operation_timeout_seconds = self._validated_duration(
|
|
993
|
+
operation_timeout_seconds,
|
|
994
|
+
description="default trusted-reader operation timeout",
|
|
995
|
+
allow_zero=False,
|
|
996
|
+
)
|
|
997
|
+
if (
|
|
998
|
+
type(busy_timeout_ms) is not int
|
|
999
|
+
or busy_timeout_ms < 0
|
|
1000
|
+
or busy_timeout_ms > 2_147_483_647
|
|
1001
|
+
):
|
|
1002
|
+
raise ValueError(
|
|
1003
|
+
"busy_timeout_ms must be an integer from 0 through 2147483647."
|
|
1004
|
+
)
|
|
1005
|
+
self._busy_timeout_seconds = busy_timeout_ms / 1_000
|
|
1006
|
+
if _test_race_artifact not in {None, "main", "wal", "shm"}:
|
|
1007
|
+
raise ValueError(
|
|
1008
|
+
"_test_race_artifact must be None, 'main', 'wal', or 'shm'."
|
|
1009
|
+
)
|
|
1010
|
+
if _test_cleanup_fault not in {
|
|
1011
|
+
None,
|
|
1012
|
+
"proof_close",
|
|
1013
|
+
"shm_unmap",
|
|
1014
|
+
"base_close",
|
|
1015
|
+
}:
|
|
1016
|
+
raise ValueError(
|
|
1017
|
+
"_test_cleanup_fault must be None, 'proof_close', "
|
|
1018
|
+
"'shm_unmap', or 'base_close'."
|
|
1019
|
+
)
|
|
1020
|
+
if _test_statement_limit_fault not in {
|
|
1021
|
+
None,
|
|
1022
|
+
"statement_limit_install",
|
|
1023
|
+
"statement_limit_verify",
|
|
1024
|
+
"statement_limit_restore",
|
|
1025
|
+
}:
|
|
1026
|
+
raise ValueError(
|
|
1027
|
+
"_test_statement_limit_fault must be None, "
|
|
1028
|
+
"'statement_limit_install', 'statement_limit_verify', or "
|
|
1029
|
+
"'statement_limit_restore'."
|
|
1030
|
+
)
|
|
1031
|
+
if _test_pre_open_callback is not None and not callable(
|
|
1032
|
+
_test_pre_open_callback
|
|
1033
|
+
):
|
|
1034
|
+
raise TypeError("_test_pre_open_callback must be callable or None.")
|
|
1035
|
+
|
|
1036
|
+
try:
|
|
1037
|
+
# Claim before any source stat/open. On POSIX, even opening and
|
|
1038
|
+
# closing a second fd for a database inode would release fcntl
|
|
1039
|
+
# locks held by an existing reader in this process.
|
|
1040
|
+
_claim_process_session(self._process_session_owner)
|
|
1041
|
+
_check_pinned_sqlite_runtime()
|
|
1042
|
+
source, main_identity = _capture_source_identity(database_path)
|
|
1043
|
+
if expected_database_instance is not None and database_instances_differ(
|
|
1044
|
+
expected_database_instance,
|
|
1045
|
+
source.database_instance,
|
|
1046
|
+
):
|
|
1047
|
+
raise TrustedLiveSourceChangedError(
|
|
1048
|
+
"A checked identity observation proved that the selected "
|
|
1049
|
+
"database differs from the instance approved before preflight."
|
|
1050
|
+
)
|
|
1051
|
+
self._source = source
|
|
1052
|
+
self._token = TrustedLiveReaderToken(source, self._token_string)
|
|
1053
|
+
|
|
1054
|
+
self._temporary_directory = tempfile.TemporaryDirectory(
|
|
1055
|
+
prefix="qplot-trusted-live-"
|
|
1056
|
+
)
|
|
1057
|
+
temporary_path = Path(self._temporary_directory.name)
|
|
1058
|
+
# macOS commonly spells its private temporary root through the
|
|
1059
|
+
# `/var -> /private/var` compatibility symlink. Canonicalise only
|
|
1060
|
+
# this application-created directory so native proof handles never
|
|
1061
|
+
# need to weaken source-path symlink rejection.
|
|
1062
|
+
if os.name != "nt":
|
|
1063
|
+
temporary_path = Path(os.path.realpath(temporary_path))
|
|
1064
|
+
self._native_temporary_path = temporary_path
|
|
1065
|
+
try:
|
|
1066
|
+
self._bootstrap = apsw.Connection(":memory:")
|
|
1067
|
+
self._bootstrap.config(
|
|
1068
|
+
apsw.SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
|
|
1069
|
+
1,
|
|
1070
|
+
)
|
|
1071
|
+
try:
|
|
1072
|
+
self._bootstrap.load_extension(
|
|
1073
|
+
str(_native_extension_path()),
|
|
1074
|
+
_NATIVE_ENTRYPOINT,
|
|
1075
|
+
)
|
|
1076
|
+
self._native_extension_loaded = True
|
|
1077
|
+
finally:
|
|
1078
|
+
self._bootstrap.config(
|
|
1079
|
+
apsw.SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION,
|
|
1080
|
+
0,
|
|
1081
|
+
)
|
|
1082
|
+
except apsw.Error as error:
|
|
1083
|
+
raise TrustedLiveReaderUnavailableError(
|
|
1084
|
+
"qPlot's pinned native trusted-reader boundary could not "
|
|
1085
|
+
"be initialised."
|
|
1086
|
+
) from error
|
|
1087
|
+
|
|
1088
|
+
resolved_path = source.database_instance.resolved_path
|
|
1089
|
+
final_preopen_instance, final_main_identity = (
|
|
1090
|
+
_capture_database_instance_for_trusted_open(
|
|
1091
|
+
source.database_instance.logical_path
|
|
1092
|
+
)
|
|
1093
|
+
)
|
|
1094
|
+
if database_instances_differ(
|
|
1095
|
+
source.database_instance,
|
|
1096
|
+
final_preopen_instance,
|
|
1097
|
+
) or (
|
|
1098
|
+
expected_database_instance is not None
|
|
1099
|
+
and database_instances_differ(
|
|
1100
|
+
expected_database_instance,
|
|
1101
|
+
final_preopen_instance,
|
|
1102
|
+
)
|
|
1103
|
+
):
|
|
1104
|
+
raise TrustedLiveSourceChangedError(
|
|
1105
|
+
"Checked identity observations proved that the selected "
|
|
1106
|
+
"database changed during trusted-reader preflight."
|
|
1107
|
+
)
|
|
1108
|
+
if final_main_identity != main_identity:
|
|
1109
|
+
raise TrustedLiveSourceChangedError(
|
|
1110
|
+
"Checked native identities proved that the selected database "
|
|
1111
|
+
"changed during trusted-reader preflight."
|
|
1112
|
+
)
|
|
1113
|
+
expected_identity_query = "&".join(
|
|
1114
|
+
(
|
|
1115
|
+
_native_expected_identity_parameters(
|
|
1116
|
+
"qplot_expected",
|
|
1117
|
+
main_identity,
|
|
1118
|
+
allow_absent=False,
|
|
1119
|
+
),
|
|
1120
|
+
_native_expected_identity_parameters(
|
|
1121
|
+
"qplot_expected_wal",
|
|
1122
|
+
source.wal_identity,
|
|
1123
|
+
allow_absent=True,
|
|
1124
|
+
),
|
|
1125
|
+
_native_expected_identity_parameters(
|
|
1126
|
+
"qplot_expected_shm",
|
|
1127
|
+
source.shm_identity,
|
|
1128
|
+
allow_absent=True,
|
|
1129
|
+
),
|
|
1130
|
+
)
|
|
1131
|
+
)
|
|
1132
|
+
query = (
|
|
1133
|
+
f"qplot_token={quote(self._token_string, safe='')}"
|
|
1134
|
+
"&qplot_temp="
|
|
1135
|
+
f"{_sqlite_uri_path(os.fspath(temporary_path))}"
|
|
1136
|
+
f"&{expected_identity_query}"
|
|
1137
|
+
)
|
|
1138
|
+
if _test_race_artifact is not None:
|
|
1139
|
+
query += f"&qplot_test_race={_test_race_artifact}"
|
|
1140
|
+
if _test_cleanup_fault is not None:
|
|
1141
|
+
query += f"&qplot_test_cleanup_fault={_test_cleanup_fault}"
|
|
1142
|
+
uri = f"{Path(resolved_path).as_uri()}?{query}"
|
|
1143
|
+
if _test_pre_open_callback is not None:
|
|
1144
|
+
_test_pre_open_callback(
|
|
1145
|
+
self._token,
|
|
1146
|
+
temporary_path,
|
|
1147
|
+
)
|
|
1148
|
+
if (
|
|
1149
|
+
_optional_file_identity(
|
|
1150
|
+
Path(f"{resolved_path}-journal"),
|
|
1151
|
+
"rollback journal",
|
|
1152
|
+
)
|
|
1153
|
+
is not None
|
|
1154
|
+
):
|
|
1155
|
+
raise TrustedLiveUnsupportedSourceError(
|
|
1156
|
+
"A rollback journal appeared during trusted-reader preflight; "
|
|
1157
|
+
"the ambiguous source family was rejected."
|
|
1158
|
+
)
|
|
1159
|
+
self._native_session_attempted = True
|
|
1160
|
+
self._connection = apsw.Connection(
|
|
1161
|
+
uri,
|
|
1162
|
+
flags=apsw.SQLITE_OPEN_READONLY | apsw.SQLITE_OPEN_URI,
|
|
1163
|
+
vfs=TRUSTED_READER_VFS_NAME,
|
|
1164
|
+
statementcachesize=0,
|
|
1165
|
+
)
|
|
1166
|
+
self._connection.config(
|
|
1167
|
+
apsw.SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE,
|
|
1168
|
+
1,
|
|
1169
|
+
)
|
|
1170
|
+
self._connection.config(apsw.SQLITE_DBCONFIG_DEFENSIVE, 1)
|
|
1171
|
+
self._connection.config(
|
|
1172
|
+
apsw.SQLITE_DBCONFIG_TRUSTED_SCHEMA,
|
|
1173
|
+
0,
|
|
1174
|
+
)
|
|
1175
|
+
# The raw/logical pre-yield row proof owns APSW's standard
|
|
1176
|
+
# tuple/scalar conversion path. A row trace or JSONB converter
|
|
1177
|
+
# could replace a bounded SQLite value with an arbitrary object.
|
|
1178
|
+
self._connection.row_trace = None
|
|
1179
|
+
self._connection.convert_jsonb = None
|
|
1180
|
+
# Each finite operation installs its own deadline-aware busy
|
|
1181
|
+
# handler. There must be no independent unbounded SQLite wait.
|
|
1182
|
+
self._connection.set_busy_timeout(0)
|
|
1183
|
+
self._connection.pragma("query_only", True)
|
|
1184
|
+
self._connection.pragma("temp_store", "MEMORY")
|
|
1185
|
+
self._connection.pragma("mmap_size", 0)
|
|
1186
|
+
# A bounded page cache lets repeated summary/axis queries over a
|
|
1187
|
+
# small plot interval reuse pages without repeated source I/O.
|
|
1188
|
+
self._connection.pragma("cache_size", -16_384)
|
|
1189
|
+
reader_reference = weakref.ref(self)
|
|
1190
|
+
|
|
1191
|
+
def read_blob(*arguments):
|
|
1192
|
+
reader = reader_reference()
|
|
1193
|
+
if reader is None:
|
|
1194
|
+
raise TrustedLiveReaderClosedError("The BLOB reader is closed.")
|
|
1195
|
+
return reader._read_blob_chunk(*arguments)
|
|
1196
|
+
|
|
1197
|
+
self._connection.create_scalar_function(
|
|
1198
|
+
"qplot_read_blob", read_blob, 5,
|
|
1199
|
+
flags=apsw.SQLITE_DIRECTONLY,
|
|
1200
|
+
)
|
|
1201
|
+
self._connection.authorizer = self._authorize
|
|
1202
|
+
if not self._connection.readonly("main"):
|
|
1203
|
+
raise TrustedLiveReaderUnavailableError(
|
|
1204
|
+
"The native VFS did not report a read-only main database."
|
|
1205
|
+
)
|
|
1206
|
+
self._validate_native_source()
|
|
1207
|
+
# Force schema and any currently present WAL/SHM opening while the
|
|
1208
|
+
# native expected-main proof is still part of construction.
|
|
1209
|
+
self.data_version()
|
|
1210
|
+
self.query("SELECT name FROM sqlite_schema LIMIT 1")
|
|
1211
|
+
self._test_statement_limit_fault = _test_statement_limit_fault
|
|
1212
|
+
except apsw.Error as error:
|
|
1213
|
+
translated = self._translate_sqlite_error(error, native_sequence=None)
|
|
1214
|
+
self._closed = True
|
|
1215
|
+
cleanup_error = self._close_resources(force=True)
|
|
1216
|
+
if cleanup_error is not None:
|
|
1217
|
+
translated.__cause__ = error
|
|
1218
|
+
raise cleanup_error from translated
|
|
1219
|
+
raise translated from error
|
|
1220
|
+
except (
|
|
1221
|
+
FileIdentityHandleCloseError,
|
|
1222
|
+
_PreflightFileDescriptorCloseError,
|
|
1223
|
+
) as error:
|
|
1224
|
+
self._preflight_cleanup_error = error
|
|
1225
|
+
self._closed = True
|
|
1226
|
+
cleanup_error = self._close_resources(force=True)
|
|
1227
|
+
if cleanup_error is None:
|
|
1228
|
+
cleanup_error = TrustedLiveCleanupError(
|
|
1229
|
+
"The trusted reader could not prove release of a preflight "
|
|
1230
|
+
"source handle; this process is quarantined."
|
|
1231
|
+
)
|
|
1232
|
+
raise cleanup_error from error
|
|
1233
|
+
except BaseException as error:
|
|
1234
|
+
self._closed = True
|
|
1235
|
+
cleanup_error = self._close_resources(force=True)
|
|
1236
|
+
if cleanup_error is not None:
|
|
1237
|
+
if cleanup_error is error:
|
|
1238
|
+
raise
|
|
1239
|
+
raise cleanup_error from error
|
|
1240
|
+
raise
|
|
1241
|
+
|
|
1242
|
+
@classmethod
|
|
1243
|
+
def open(
|
|
1244
|
+
cls,
|
|
1245
|
+
database_path: str | os.PathLike[str],
|
|
1246
|
+
*,
|
|
1247
|
+
expected_database_instance: DatabaseInstance | None = None,
|
|
1248
|
+
busy_timeout_ms: int = 5_000,
|
|
1249
|
+
operation_timeout_seconds: float = (DEFAULT_TRUSTED_OPERATION_TIMEOUT_SECONDS),
|
|
1250
|
+
_test_race_artifact: str | None = None,
|
|
1251
|
+
_test_cleanup_fault: str | None = None,
|
|
1252
|
+
_test_statement_limit_fault: str | None = None,
|
|
1253
|
+
_test_pre_open_callback: (
|
|
1254
|
+
Callable[[TrustedLiveReaderToken, Path], None] | None
|
|
1255
|
+
) = None,
|
|
1256
|
+
) -> TrustedLiveReader:
|
|
1257
|
+
"""Open the selected source directly, with no snapshot fallback.
|
|
1258
|
+
|
|
1259
|
+
``operation_timeout_seconds`` is the finite default for later calls
|
|
1260
|
+
which do not provide their own ``timeout`` or ``deadline``.
|
|
1261
|
+
"""
|
|
1262
|
+
return cls(
|
|
1263
|
+
database_path,
|
|
1264
|
+
expected_database_instance=expected_database_instance,
|
|
1265
|
+
busy_timeout_ms=busy_timeout_ms,
|
|
1266
|
+
operation_timeout_seconds=operation_timeout_seconds,
|
|
1267
|
+
_test_race_artifact=_test_race_artifact,
|
|
1268
|
+
_test_cleanup_fault=_test_cleanup_fault,
|
|
1269
|
+
_test_statement_limit_fault=_test_statement_limit_fault,
|
|
1270
|
+
_test_pre_open_callback=_test_pre_open_callback,
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
@property
|
|
1274
|
+
def source_identity(self) -> TrustedLiveSourceIdentity:
|
|
1275
|
+
return self._source
|
|
1276
|
+
|
|
1277
|
+
@property
|
|
1278
|
+
def token(self) -> TrustedLiveReaderToken:
|
|
1279
|
+
return self._token
|
|
1280
|
+
|
|
1281
|
+
@property
|
|
1282
|
+
def closed(self) -> bool:
|
|
1283
|
+
return self._closed
|
|
1284
|
+
|
|
1285
|
+
@property
|
|
1286
|
+
def temporary_directory(self) -> Path | None:
|
|
1287
|
+
temporary_directory = self._temporary_directory
|
|
1288
|
+
if temporary_directory is None:
|
|
1289
|
+
return None
|
|
1290
|
+
return self._native_temporary_path
|
|
1291
|
+
|
|
1292
|
+
def _check_thread(self) -> None:
|
|
1293
|
+
if threading.get_ident() != self._owner_thread:
|
|
1294
|
+
raise TrustedLiveReaderThreadError(
|
|
1295
|
+
"Trusted live readers may only be used by their owner thread."
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
def _require_open(self) -> apsw.Connection:
|
|
1299
|
+
self._check_thread()
|
|
1300
|
+
if self._invalidated:
|
|
1301
|
+
raise TrustedLiveSourceChangedError(
|
|
1302
|
+
"The trusted database instance changed; close this reader and "
|
|
1303
|
+
"open a new one explicitly."
|
|
1304
|
+
)
|
|
1305
|
+
if self._closed or self._connection is None:
|
|
1306
|
+
raise TrustedLiveReaderClosedError("The trusted live reader is closed.")
|
|
1307
|
+
return self._connection
|
|
1308
|
+
|
|
1309
|
+
@staticmethod
|
|
1310
|
+
def _validated_duration(
|
|
1311
|
+
value: float,
|
|
1312
|
+
*,
|
|
1313
|
+
description: str,
|
|
1314
|
+
allow_zero: bool,
|
|
1315
|
+
) -> float:
|
|
1316
|
+
if isinstance(value, bool):
|
|
1317
|
+
raise ValueError(f"{description} must be a finite number of seconds.")
|
|
1318
|
+
try:
|
|
1319
|
+
duration = float(value)
|
|
1320
|
+
except (TypeError, ValueError) as error:
|
|
1321
|
+
raise ValueError(
|
|
1322
|
+
f"{description} must be a finite number of seconds."
|
|
1323
|
+
) from error
|
|
1324
|
+
if (
|
|
1325
|
+
not math.isfinite(duration)
|
|
1326
|
+
or duration < 0
|
|
1327
|
+
or (duration == 0 and not allow_zero)
|
|
1328
|
+
):
|
|
1329
|
+
qualifier = "non-negative" if allow_zero else "positive"
|
|
1330
|
+
raise ValueError(
|
|
1331
|
+
f"{description} must be a finite {qualifier} number of seconds."
|
|
1332
|
+
)
|
|
1333
|
+
return duration
|
|
1334
|
+
|
|
1335
|
+
def _operation_deadline(
|
|
1336
|
+
self,
|
|
1337
|
+
*,
|
|
1338
|
+
timeout: float | None,
|
|
1339
|
+
deadline: float | None,
|
|
1340
|
+
) -> tuple[float, float]:
|
|
1341
|
+
started_at = time.monotonic()
|
|
1342
|
+
candidates: list[float] = []
|
|
1343
|
+
if timeout is not None:
|
|
1344
|
+
candidates.append(
|
|
1345
|
+
started_at
|
|
1346
|
+
+ self._validated_duration(
|
|
1347
|
+
timeout,
|
|
1348
|
+
description="trusted-reader operation timeout",
|
|
1349
|
+
allow_zero=True,
|
|
1350
|
+
)
|
|
1351
|
+
)
|
|
1352
|
+
if deadline is not None:
|
|
1353
|
+
if isinstance(deadline, bool):
|
|
1354
|
+
raise ValueError("deadline must be a finite monotonic timestamp.")
|
|
1355
|
+
try:
|
|
1356
|
+
absolute_deadline = float(deadline)
|
|
1357
|
+
except (TypeError, ValueError) as error:
|
|
1358
|
+
raise ValueError(
|
|
1359
|
+
"deadline must be a finite monotonic timestamp."
|
|
1360
|
+
) from error
|
|
1361
|
+
if not math.isfinite(absolute_deadline):
|
|
1362
|
+
raise ValueError("deadline must be a finite monotonic timestamp.")
|
|
1363
|
+
candidates.append(absolute_deadline)
|
|
1364
|
+
if not candidates:
|
|
1365
|
+
candidates.append(started_at + self._default_operation_timeout_seconds)
|
|
1366
|
+
return started_at, min(candidates)
|
|
1367
|
+
|
|
1368
|
+
def interrupt(self) -> bool:
|
|
1369
|
+
"""Cancel the currently active finite operation from any thread.
|
|
1370
|
+
|
|
1371
|
+
The request is generation-bound: calling this method while the reader
|
|
1372
|
+
is idle does not poison the next operation. ``True`` means an active
|
|
1373
|
+
operation was marked and ``sqlite3_interrupt`` was invoked.
|
|
1374
|
+
"""
|
|
1375
|
+
|
|
1376
|
+
with self._operation_mutex:
|
|
1377
|
+
control = self._active_operation
|
|
1378
|
+
connection = self._connection
|
|
1379
|
+
if control is None or connection is None:
|
|
1380
|
+
return False
|
|
1381
|
+
if control.abort_reason is None:
|
|
1382
|
+
control.abort_reason = "cancelled"
|
|
1383
|
+
# Keep publication and sqlite3_interrupt() in one generation lock.
|
|
1384
|
+
# Otherwise the old operation could finish and a new one could be
|
|
1385
|
+
# published between these two actions, receiving a stale interrupt.
|
|
1386
|
+
connection.interrupt()
|
|
1387
|
+
return True
|
|
1388
|
+
|
|
1389
|
+
def _mark_operation_abort(
|
|
1390
|
+
self,
|
|
1391
|
+
control: _TrustedOperationControl,
|
|
1392
|
+
reason: str,
|
|
1393
|
+
) -> None:
|
|
1394
|
+
with self._operation_mutex:
|
|
1395
|
+
# Cleanup clears the published active generation before consulting
|
|
1396
|
+
# the native control channel. It may still need to record a
|
|
1397
|
+
# deadline/cancellation against that same, now unpublished control.
|
|
1398
|
+
if (
|
|
1399
|
+
self._active_operation is None or self._active_operation is control
|
|
1400
|
+
) and control.abort_reason is None:
|
|
1401
|
+
control.abort_reason = reason
|
|
1402
|
+
|
|
1403
|
+
def _operation_abort_reason(
|
|
1404
|
+
self,
|
|
1405
|
+
control: _TrustedOperationControl,
|
|
1406
|
+
) -> str | None:
|
|
1407
|
+
with self._operation_mutex:
|
|
1408
|
+
return control.abort_reason
|
|
1409
|
+
|
|
1410
|
+
def _raise_if_operation_aborted(
|
|
1411
|
+
self,
|
|
1412
|
+
control: _TrustedOperationControl,
|
|
1413
|
+
) -> None:
|
|
1414
|
+
cancel_event = control.cancel_event
|
|
1415
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
1416
|
+
self._mark_operation_abort(control, "cancelled")
|
|
1417
|
+
if time.monotonic() >= control.deadline:
|
|
1418
|
+
self._mark_operation_abort(control, "deadline")
|
|
1419
|
+
reason = self._operation_abort_reason(control)
|
|
1420
|
+
if reason == "cancelled":
|
|
1421
|
+
raise TrustedLiveCancelledError("The trusted database read was cancelled.")
|
|
1422
|
+
if reason == "deadline":
|
|
1423
|
+
raise TrustedLiveDeadlineExceededError(
|
|
1424
|
+
"The trusted database read deadline was exceeded."
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
def _progress_handler(self, control: _TrustedOperationControl) -> bool:
|
|
1428
|
+
try:
|
|
1429
|
+
self._raise_if_operation_aborted(control)
|
|
1430
|
+
except (TrustedLiveCancelledError, TrustedLiveDeadlineExceededError):
|
|
1431
|
+
return True
|
|
1432
|
+
return False
|
|
1433
|
+
|
|
1434
|
+
def _busy_handler(
|
|
1435
|
+
self,
|
|
1436
|
+
control: _TrustedOperationControl,
|
|
1437
|
+
prior_calls: int,
|
|
1438
|
+
) -> bool:
|
|
1439
|
+
del prior_calls
|
|
1440
|
+
try:
|
|
1441
|
+
self._raise_if_operation_aborted(control)
|
|
1442
|
+
except (TrustedLiveCancelledError, TrustedLiveDeadlineExceededError):
|
|
1443
|
+
return False
|
|
1444
|
+
now = time.monotonic()
|
|
1445
|
+
if now >= control.busy_deadline:
|
|
1446
|
+
return False
|
|
1447
|
+
delay = min(_BUSY_RETRY_QUANTUM_SECONDS, control.busy_deadline - now)
|
|
1448
|
+
cancel_event = control.cancel_event
|
|
1449
|
+
if cancel_event is None:
|
|
1450
|
+
time.sleep(delay)
|
|
1451
|
+
else:
|
|
1452
|
+
cancel_event.wait(delay)
|
|
1453
|
+
try:
|
|
1454
|
+
self._raise_if_operation_aborted(control)
|
|
1455
|
+
except (TrustedLiveCancelledError, TrustedLiveDeadlineExceededError):
|
|
1456
|
+
return False
|
|
1457
|
+
return time.monotonic() < control.busy_deadline
|
|
1458
|
+
|
|
1459
|
+
def _start_operation(
|
|
1460
|
+
self,
|
|
1461
|
+
*,
|
|
1462
|
+
timeout: float | None,
|
|
1463
|
+
deadline: float | None,
|
|
1464
|
+
cancel_event: threading.Event | None,
|
|
1465
|
+
) -> tuple[apsw.Connection, _TrustedOperationControl]:
|
|
1466
|
+
connection = self._require_open()
|
|
1467
|
+
if cancel_event is not None and not isinstance(cancel_event, threading.Event):
|
|
1468
|
+
raise TypeError("cancel_event must be a threading.Event or None.")
|
|
1469
|
+
started_at, operation_deadline = self._operation_deadline(
|
|
1470
|
+
timeout=timeout,
|
|
1471
|
+
deadline=deadline,
|
|
1472
|
+
)
|
|
1473
|
+
with self._operation_mutex:
|
|
1474
|
+
if self._active_operation is not None:
|
|
1475
|
+
raise TrustedLiveTransactionError(
|
|
1476
|
+
"Nested trusted-reader operations are not supported."
|
|
1477
|
+
)
|
|
1478
|
+
generation = self._next_operation_generation
|
|
1479
|
+
self._next_operation_generation += 1
|
|
1480
|
+
control = _TrustedOperationControl(
|
|
1481
|
+
generation=generation,
|
|
1482
|
+
started_at=started_at,
|
|
1483
|
+
deadline=operation_deadline,
|
|
1484
|
+
busy_deadline=min(
|
|
1485
|
+
operation_deadline,
|
|
1486
|
+
started_at + self._busy_timeout_seconds,
|
|
1487
|
+
),
|
|
1488
|
+
cancel_event=cancel_event,
|
|
1489
|
+
)
|
|
1490
|
+
self._active_operation = control
|
|
1491
|
+
return connection, control
|
|
1492
|
+
|
|
1493
|
+
@staticmethod
|
|
1494
|
+
def _progress_handler_id(control: _TrustedOperationControl) -> tuple[str, int]:
|
|
1495
|
+
return ("qplot-trusted-operation", control.generation)
|
|
1496
|
+
|
|
1497
|
+
def _install_operation_handlers(
|
|
1498
|
+
self,
|
|
1499
|
+
connection: apsw.Connection,
|
|
1500
|
+
control: _TrustedOperationControl,
|
|
1501
|
+
) -> None:
|
|
1502
|
+
connection.set_progress_handler(
|
|
1503
|
+
lambda: self._progress_handler(control),
|
|
1504
|
+
_PROGRESS_HANDLER_STEPS,
|
|
1505
|
+
id=self._progress_handler_id(control),
|
|
1506
|
+
)
|
|
1507
|
+
connection.set_busy_handler(
|
|
1508
|
+
lambda prior_calls: self._busy_handler(control, prior_calls)
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
def _remove_operation_handlers(
|
|
1512
|
+
self,
|
|
1513
|
+
connection: apsw.Connection,
|
|
1514
|
+
control: _TrustedOperationControl,
|
|
1515
|
+
) -> list[BaseException]:
|
|
1516
|
+
errors: list[BaseException] = []
|
|
1517
|
+
try:
|
|
1518
|
+
connection.set_progress_handler(
|
|
1519
|
+
None,
|
|
1520
|
+
id=self._progress_handler_id(control),
|
|
1521
|
+
)
|
|
1522
|
+
except BaseException as error:
|
|
1523
|
+
errors.append(error)
|
|
1524
|
+
try:
|
|
1525
|
+
connection.set_busy_handler(None)
|
|
1526
|
+
except BaseException as error:
|
|
1527
|
+
errors.append(error)
|
|
1528
|
+
return errors
|
|
1529
|
+
|
|
1530
|
+
def _read_blob_chunk(self, *arguments: Any) -> bytes:
|
|
1531
|
+
"""Read a bounded slice without materialising SQLite's entire value.
|
|
1532
|
+
|
|
1533
|
+
The SQL entry point shares query_batch's transaction, source proof,
|
|
1534
|
+
cancellation, result limits, and cleanup. It opens only main-database
|
|
1535
|
+
incremental BLOB handles with writeable=False, and exposes no handle.
|
|
1536
|
+
"""
|
|
1537
|
+
self._check_thread()
|
|
1538
|
+
if len(arguments) != 5:
|
|
1539
|
+
raise TrustedLiveSqlRejectedError("BLOB reads require five arguments.")
|
|
1540
|
+
table, column, row_id, offset, length = arguments
|
|
1541
|
+
control = self._active_operation
|
|
1542
|
+
connection = self._connection
|
|
1543
|
+
if control is None or connection is None:
|
|
1544
|
+
raise TrustedLiveSqlRejectedError("BLOB reads require an active finite query.")
|
|
1545
|
+
self._raise_if_operation_aborted(control)
|
|
1546
|
+
if (not isinstance(table, str) or not isinstance(column, str)
|
|
1547
|
+
or not 0 < len(table) <= 1024 or not 0 < len(column) <= 1024
|
|
1548
|
+
or "\x00" in table or "\x00" in column
|
|
1549
|
+
or type(row_id) is not int or type(offset) is not int
|
|
1550
|
+
or type(length) is not int or offset < 0
|
|
1551
|
+
or not 0 <= length <= 256 * 1024):
|
|
1552
|
+
raise TrustedLiveSqlRejectedError("Invalid bounded BLOB read arguments.")
|
|
1553
|
+
if length > connection.limit(apsw.SQLITE_LIMIT_LENGTH):
|
|
1554
|
+
raise TrustedLiveResultLimitError("BLOB chunk exceeds the statement value limit.")
|
|
1555
|
+
blob = connection.blob_open("main", table, column, row_id, False)
|
|
1556
|
+
try:
|
|
1557
|
+
self._raise_if_operation_aborted(control)
|
|
1558
|
+
blob.seek(offset)
|
|
1559
|
+
value = blob.read(length)
|
|
1560
|
+
self._raise_if_operation_aborted(control)
|
|
1561
|
+
return value
|
|
1562
|
+
finally:
|
|
1563
|
+
try:
|
|
1564
|
+
blob.close()
|
|
1565
|
+
except BaseException as error:
|
|
1566
|
+
control.blob_cleanup_error = TrustedLiveCleanupError(
|
|
1567
|
+
"Could not prove incremental BLOB handle cleanup."
|
|
1568
|
+
)
|
|
1569
|
+
raise control.blob_cleanup_error from error
|
|
1570
|
+
|
|
1571
|
+
def _record_policy_denial(self) -> int:
|
|
1572
|
+
"""Mark the active operation and return SQLite's deny result."""
|
|
1573
|
+
|
|
1574
|
+
with self._operation_mutex:
|
|
1575
|
+
control = self._active_operation
|
|
1576
|
+
if control is not None:
|
|
1577
|
+
control.policy_denied = True
|
|
1578
|
+
return apsw.SQLITE_DENY
|
|
1579
|
+
|
|
1580
|
+
def _authorize(
|
|
1581
|
+
self,
|
|
1582
|
+
operation: int,
|
|
1583
|
+
parameter_one: str | None,
|
|
1584
|
+
parameter_two: str | None,
|
|
1585
|
+
database_name: str | None,
|
|
1586
|
+
trigger_or_view: str | None,
|
|
1587
|
+
) -> int:
|
|
1588
|
+
del trigger_or_view
|
|
1589
|
+
if operation in {
|
|
1590
|
+
apsw.SQLITE_SELECT,
|
|
1591
|
+
apsw.SQLITE_READ,
|
|
1592
|
+
apsw.SQLITE_RECURSIVE,
|
|
1593
|
+
}:
|
|
1594
|
+
return apsw.SQLITE_OK
|
|
1595
|
+
if operation == apsw.SQLITE_FUNCTION:
|
|
1596
|
+
function_name = (parameter_two or "").casefold()
|
|
1597
|
+
if function_name in {
|
|
1598
|
+
"eval",
|
|
1599
|
+
"fts3_tokenizer",
|
|
1600
|
+
"load_extension",
|
|
1601
|
+
"readfile",
|
|
1602
|
+
"writefile",
|
|
1603
|
+
}:
|
|
1604
|
+
return self._record_policy_denial()
|
|
1605
|
+
return apsw.SQLITE_OK
|
|
1606
|
+
if operation == apsw.SQLITE_PRAGMA:
|
|
1607
|
+
pragma_name = (parameter_one or "").casefold()
|
|
1608
|
+
if (
|
|
1609
|
+
pragma_name in {
|
|
1610
|
+
"data_version", "user_version", "application_id",
|
|
1611
|
+
"page_count", "page_size",
|
|
1612
|
+
}
|
|
1613
|
+
and parameter_two is None
|
|
1614
|
+
and database_name in {None, "main"}
|
|
1615
|
+
):
|
|
1616
|
+
return apsw.SQLITE_OK
|
|
1617
|
+
return self._record_policy_denial()
|
|
1618
|
+
if operation == apsw.SQLITE_TRANSACTION and self._internal_transaction_control:
|
|
1619
|
+
return apsw.SQLITE_OK
|
|
1620
|
+
return self._record_policy_denial()
|
|
1621
|
+
|
|
1622
|
+
def _execute_transaction_control(
|
|
1623
|
+
self,
|
|
1624
|
+
sql: str,
|
|
1625
|
+
) -> None:
|
|
1626
|
+
self._check_thread()
|
|
1627
|
+
connection = self._connection
|
|
1628
|
+
if self._closed or connection is None:
|
|
1629
|
+
raise TrustedLiveReaderClosedError("The trusted live reader is closed.")
|
|
1630
|
+
self._internal_transaction_control = True
|
|
1631
|
+
cursor: apsw.Cursor | None = None
|
|
1632
|
+
body_error: BaseException | None = None
|
|
1633
|
+
body_traceback: TracebackType | None = None
|
|
1634
|
+
try:
|
|
1635
|
+
cursor = connection.cursor()
|
|
1636
|
+
cursor.execute(sql).fetchall()
|
|
1637
|
+
except BaseException as error:
|
|
1638
|
+
body_error = error
|
|
1639
|
+
body_traceback = error.__traceback__
|
|
1640
|
+
finally:
|
|
1641
|
+
self._internal_transaction_control = False
|
|
1642
|
+
if cursor is not None:
|
|
1643
|
+
try:
|
|
1644
|
+
cursor.close()
|
|
1645
|
+
except BaseException as close_error:
|
|
1646
|
+
if body_error is None:
|
|
1647
|
+
body_error = close_error
|
|
1648
|
+
body_traceback = close_error.__traceback__
|
|
1649
|
+
else:
|
|
1650
|
+
body_error.add_note(
|
|
1651
|
+
f"SQLite cursor cleanup also failed: {close_error!r}"
|
|
1652
|
+
)
|
|
1653
|
+
if body_error is not None:
|
|
1654
|
+
raise body_error.with_traceback(body_traceback)
|
|
1655
|
+
|
|
1656
|
+
def _install_result_length_limit(
|
|
1657
|
+
self,
|
|
1658
|
+
connection: apsw.Connection,
|
|
1659
|
+
control: _TrustedOperationControl,
|
|
1660
|
+
) -> _TrustedSqliteLengthLimit:
|
|
1661
|
+
"""Install and verify the operation's absolute SQLite scalar cap."""
|
|
1662
|
+
|
|
1663
|
+
self._raise_if_operation_aborted(control)
|
|
1664
|
+
try:
|
|
1665
|
+
previous = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1666
|
+
except BaseException as error:
|
|
1667
|
+
raise TrustedLiveCleanupError(
|
|
1668
|
+
"The trusted reader could not inspect SQLite's prior "
|
|
1669
|
+
"result-length limit; the connection must be retired."
|
|
1670
|
+
) from error
|
|
1671
|
+
effective = min(previous, TRUSTED_LIVE_MAX_SCALAR_BYTES)
|
|
1672
|
+
try:
|
|
1673
|
+
observed_previous = connection.limit(
|
|
1674
|
+
apsw.SQLITE_LIMIT_LENGTH,
|
|
1675
|
+
effective,
|
|
1676
|
+
)
|
|
1677
|
+
observed_effective = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1678
|
+
except BaseException as error:
|
|
1679
|
+
setup_error = TrustedLiveCleanupError(
|
|
1680
|
+
"The trusted reader could not install its SQLite result-length "
|
|
1681
|
+
"limit; the connection must be retired."
|
|
1682
|
+
)
|
|
1683
|
+
try:
|
|
1684
|
+
connection.limit(apsw.SQLITE_LIMIT_LENGTH, previous)
|
|
1685
|
+
restored = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1686
|
+
except BaseException as restore_error:
|
|
1687
|
+
setup_error.add_note(
|
|
1688
|
+
f"SQLite limit restoration also failed: {restore_error!r}"
|
|
1689
|
+
)
|
|
1690
|
+
else:
|
|
1691
|
+
if restored != previous:
|
|
1692
|
+
setup_error.add_note(
|
|
1693
|
+
"SQLite did not confirm restoration of its prior length limit."
|
|
1694
|
+
)
|
|
1695
|
+
raise setup_error from error
|
|
1696
|
+
if observed_previous != previous or observed_effective != effective:
|
|
1697
|
+
try:
|
|
1698
|
+
connection.limit(apsw.SQLITE_LIMIT_LENGTH, previous)
|
|
1699
|
+
except BaseException as restore_error:
|
|
1700
|
+
cleanup_failure = TrustedLiveCleanupError(
|
|
1701
|
+
"The trusted reader could neither verify nor restore its "
|
|
1702
|
+
"SQLite result-length limit."
|
|
1703
|
+
)
|
|
1704
|
+
cleanup_failure.add_note(
|
|
1705
|
+
f"SQLite limit restoration also failed: {restore_error!r}"
|
|
1706
|
+
)
|
|
1707
|
+
raise cleanup_failure from restore_error
|
|
1708
|
+
raise TrustedLiveCleanupError(
|
|
1709
|
+
"The trusted reader could not verify its SQLite result-length "
|
|
1710
|
+
"limit; the connection must be retired."
|
|
1711
|
+
)
|
|
1712
|
+
control.operation_result_length_limit = effective
|
|
1713
|
+
control.result_length_limit = effective
|
|
1714
|
+
return _TrustedSqliteLengthLimit(previous=previous, effective=effective)
|
|
1715
|
+
|
|
1716
|
+
def _consume_statement_limit_fault(self, phase: str) -> bool:
|
|
1717
|
+
if (
|
|
1718
|
+
not self._test_statement_limit_fault_consumed
|
|
1719
|
+
and self._test_statement_limit_fault == phase
|
|
1720
|
+
):
|
|
1721
|
+
self._test_statement_limit_fault_consumed = True
|
|
1722
|
+
return True
|
|
1723
|
+
return False
|
|
1724
|
+
|
|
1725
|
+
@staticmethod
|
|
1726
|
+
def _note_best_effort_limit_restore(
|
|
1727
|
+
connection: apsw.Connection,
|
|
1728
|
+
target: int,
|
|
1729
|
+
setup_error: TrustedLiveCleanupError,
|
|
1730
|
+
) -> None:
|
|
1731
|
+
"""Try to regain a known limit after uncertain statement setup."""
|
|
1732
|
+
|
|
1733
|
+
try:
|
|
1734
|
+
connection.limit(apsw.SQLITE_LIMIT_LENGTH, target)
|
|
1735
|
+
restored = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1736
|
+
except BaseException as restore_error:
|
|
1737
|
+
setup_error.add_note(
|
|
1738
|
+
f"SQLite limit restoration also failed: {restore_error!r}"
|
|
1739
|
+
)
|
|
1740
|
+
else:
|
|
1741
|
+
if restored != target:
|
|
1742
|
+
setup_error.add_note(
|
|
1743
|
+
"SQLite did not confirm restoration of the operation-wide "
|
|
1744
|
+
"length limit."
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
def _install_statement_result_length_limit(
|
|
1748
|
+
self,
|
|
1749
|
+
connection: apsw.Connection,
|
|
1750
|
+
control: _TrustedOperationControl,
|
|
1751
|
+
column_count: int,
|
|
1752
|
+
) -> _TrustedSqliteLengthLimit:
|
|
1753
|
+
"""Install the width-derived cap before APSW can produce one row."""
|
|
1754
|
+
|
|
1755
|
+
self._raise_if_operation_aborted(control)
|
|
1756
|
+
baseline = control.operation_result_length_limit
|
|
1757
|
+
if baseline is None:
|
|
1758
|
+
raise TrustedLiveCleanupError(
|
|
1759
|
+
"The trusted reader lost its operation-wide SQLite result-length "
|
|
1760
|
+
"baseline; the connection must be retired."
|
|
1761
|
+
)
|
|
1762
|
+
try:
|
|
1763
|
+
observed_baseline = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1764
|
+
row_trace = connection.row_trace
|
|
1765
|
+
convert_jsonb = connection.convert_jsonb
|
|
1766
|
+
except BaseException as error:
|
|
1767
|
+
raise TrustedLiveCleanupError(
|
|
1768
|
+
"The trusted reader could not inspect the SQLite row-conversion "
|
|
1769
|
+
"and result-length state; the connection must be retired."
|
|
1770
|
+
) from error
|
|
1771
|
+
if observed_baseline != baseline:
|
|
1772
|
+
raise TrustedLiveCleanupError(
|
|
1773
|
+
"SQLite did not retain the verified operation-wide result-length "
|
|
1774
|
+
"baseline; the connection must be retired."
|
|
1775
|
+
)
|
|
1776
|
+
if row_trace is not None or convert_jsonb is not None:
|
|
1777
|
+
raise TrustedLiveCleanupError(
|
|
1778
|
+
"APSW's standard tuple/scalar row conversion was not active; "
|
|
1779
|
+
"the raw/logical pre-yield row bounds could not be proved."
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
effective = _trusted_statement_length_limit(baseline, column_count)
|
|
1783
|
+
self._raise_if_operation_aborted(control)
|
|
1784
|
+
if self._consume_statement_limit_fault("statement_limit_install"):
|
|
1785
|
+
raise TrustedLiveCleanupError(
|
|
1786
|
+
"Injected per-statement SQLite result-length installation "
|
|
1787
|
+
"failure; the connection must be retired."
|
|
1788
|
+
)
|
|
1789
|
+
try:
|
|
1790
|
+
observed_previous = connection.limit(
|
|
1791
|
+
apsw.SQLITE_LIMIT_LENGTH,
|
|
1792
|
+
effective,
|
|
1793
|
+
)
|
|
1794
|
+
observed_effective = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1795
|
+
except BaseException as error:
|
|
1796
|
+
setup_error = TrustedLiveCleanupError(
|
|
1797
|
+
"The trusted reader could not install its per-statement SQLite "
|
|
1798
|
+
"result-length limit; the connection must be retired."
|
|
1799
|
+
)
|
|
1800
|
+
self._note_best_effort_limit_restore(connection, baseline, setup_error)
|
|
1801
|
+
raise setup_error from error
|
|
1802
|
+
|
|
1803
|
+
verification_fault = self._consume_statement_limit_fault(
|
|
1804
|
+
"statement_limit_verify"
|
|
1805
|
+
)
|
|
1806
|
+
if (
|
|
1807
|
+
verification_fault
|
|
1808
|
+
or observed_previous != baseline
|
|
1809
|
+
or observed_effective != effective
|
|
1810
|
+
):
|
|
1811
|
+
setup_error = TrustedLiveCleanupError(
|
|
1812
|
+
"The trusted reader could not verify its per-statement SQLite "
|
|
1813
|
+
"result-length limit; the connection must be retired."
|
|
1814
|
+
)
|
|
1815
|
+
self._note_best_effort_limit_restore(connection, baseline, setup_error)
|
|
1816
|
+
raise setup_error
|
|
1817
|
+
|
|
1818
|
+
control.result_length_limit = effective
|
|
1819
|
+
return _TrustedSqliteLengthLimit(previous=baseline, effective=effective)
|
|
1820
|
+
|
|
1821
|
+
def _restore_statement_result_length_limit(
|
|
1822
|
+
self,
|
|
1823
|
+
connection: apsw.Connection,
|
|
1824
|
+
control: _TrustedOperationControl,
|
|
1825
|
+
installed: _TrustedSqliteLengthLimit,
|
|
1826
|
+
) -> TrustedLiveCleanupError | None:
|
|
1827
|
+
"""Restore and verify the operation baseline after one statement."""
|
|
1828
|
+
|
|
1829
|
+
if self._consume_statement_limit_fault("statement_limit_restore"):
|
|
1830
|
+
return TrustedLiveCleanupError(
|
|
1831
|
+
"Injected per-statement SQLite result-length restoration "
|
|
1832
|
+
"failure; the connection must be retired."
|
|
1833
|
+
)
|
|
1834
|
+
try:
|
|
1835
|
+
observed_effective = connection.limit(
|
|
1836
|
+
apsw.SQLITE_LIMIT_LENGTH,
|
|
1837
|
+
installed.previous,
|
|
1838
|
+
)
|
|
1839
|
+
observed_baseline = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1840
|
+
except BaseException as error:
|
|
1841
|
+
return TrustedLiveCleanupError(
|
|
1842
|
+
"The trusted reader could not restore the operation-wide SQLite "
|
|
1843
|
+
f"result-length baseline: {error}"
|
|
1844
|
+
)
|
|
1845
|
+
if (
|
|
1846
|
+
observed_effective != installed.effective
|
|
1847
|
+
or observed_baseline != installed.previous
|
|
1848
|
+
):
|
|
1849
|
+
return TrustedLiveCleanupError(
|
|
1850
|
+
"The trusted reader could not verify restoration of the "
|
|
1851
|
+
"operation-wide SQLite result-length baseline."
|
|
1852
|
+
)
|
|
1853
|
+
control.result_length_limit = installed.previous
|
|
1854
|
+
return None
|
|
1855
|
+
|
|
1856
|
+
def _restore_result_length_limit(
|
|
1857
|
+
self,
|
|
1858
|
+
connection: apsw.Connection,
|
|
1859
|
+
control: _TrustedOperationControl,
|
|
1860
|
+
installed: _TrustedSqliteLengthLimit,
|
|
1861
|
+
) -> TrustedLiveCleanupError | None:
|
|
1862
|
+
"""Restore and verify SQLite's prior runtime length limit."""
|
|
1863
|
+
|
|
1864
|
+
try:
|
|
1865
|
+
observed_effective = connection.limit(
|
|
1866
|
+
apsw.SQLITE_LIMIT_LENGTH,
|
|
1867
|
+
installed.previous,
|
|
1868
|
+
)
|
|
1869
|
+
observed_previous = connection.limit(apsw.SQLITE_LIMIT_LENGTH)
|
|
1870
|
+
except BaseException as error:
|
|
1871
|
+
return TrustedLiveCleanupError(
|
|
1872
|
+
"The trusted reader could not restore its prior SQLite "
|
|
1873
|
+
f"result-length limit: {error}"
|
|
1874
|
+
)
|
|
1875
|
+
if (
|
|
1876
|
+
observed_effective != installed.effective
|
|
1877
|
+
or observed_previous != installed.previous
|
|
1878
|
+
):
|
|
1879
|
+
return TrustedLiveCleanupError(
|
|
1880
|
+
"The trusted reader could not verify restoration of its prior "
|
|
1881
|
+
"SQLite result-length limit."
|
|
1882
|
+
)
|
|
1883
|
+
control.operation_result_length_limit = None
|
|
1884
|
+
control.result_length_limit = None
|
|
1885
|
+
return None
|
|
1886
|
+
|
|
1887
|
+
def _query_spec_in_transaction(
|
|
1888
|
+
self,
|
|
1889
|
+
connection: apsw.Connection,
|
|
1890
|
+
query: TrustedQuery,
|
|
1891
|
+
result_budget: _TrustedResultBudget,
|
|
1892
|
+
control: _TrustedOperationControl,
|
|
1893
|
+
operation_cleanup_errors: list[BaseException],
|
|
1894
|
+
) -> TrustedQueryResult:
|
|
1895
|
+
sql = query.sql
|
|
1896
|
+
bindings = query.bindings
|
|
1897
|
+
if not isinstance(sql, str) or not sql.strip() or "\x00" in sql:
|
|
1898
|
+
raise TrustedLiveSqlRejectedError(
|
|
1899
|
+
"A trusted query must be one non-empty SQLite statement."
|
|
1900
|
+
)
|
|
1901
|
+
details = apsw.ext.query_info(connection, sql, bindings)
|
|
1902
|
+
if (
|
|
1903
|
+
(details.query_remaining is not None and details.query_remaining.strip())
|
|
1904
|
+
or not details.is_readonly
|
|
1905
|
+
or not details.has_vdbe
|
|
1906
|
+
or details.is_explain
|
|
1907
|
+
):
|
|
1908
|
+
raise TrustedLiveSqlRejectedError(
|
|
1909
|
+
"The trusted reader accepts exactly one ordinary read-only "
|
|
1910
|
+
"SELECT or supported read-only metadata PRAGMA statement."
|
|
1911
|
+
)
|
|
1912
|
+
columns = result_budget.start_result(details.description)
|
|
1913
|
+
cursor: apsw.Cursor | None = None
|
|
1914
|
+
statement_length_limit: _TrustedSqliteLengthLimit | None = None
|
|
1915
|
+
body_error: BaseException | None = None
|
|
1916
|
+
body_cause: BaseException | None = None
|
|
1917
|
+
body_traceback: TracebackType | None = None
|
|
1918
|
+
result: TrustedQueryResult | None = None
|
|
1919
|
+
try:
|
|
1920
|
+
try:
|
|
1921
|
+
statement_length_limit = self._install_statement_result_length_limit(
|
|
1922
|
+
connection,
|
|
1923
|
+
control,
|
|
1924
|
+
len(columns),
|
|
1925
|
+
)
|
|
1926
|
+
except TrustedLiveCleanupError:
|
|
1927
|
+
operation_cleanup_errors.append(
|
|
1928
|
+
TrustedLiveCleanupError(
|
|
1929
|
+
"Per-statement SQLite result-limit setup was not proved "
|
|
1930
|
+
"safe; the reader connection must be retired."
|
|
1931
|
+
)
|
|
1932
|
+
)
|
|
1933
|
+
raise
|
|
1934
|
+
self._raise_if_operation_aborted(control)
|
|
1935
|
+
cursor = connection.cursor()
|
|
1936
|
+
cursor.row_trace = None
|
|
1937
|
+
cursor.execute(details.first_query, bindings)
|
|
1938
|
+
try:
|
|
1939
|
+
execution_description = cursor.get_description()
|
|
1940
|
+
except apsw.ExecutionCompleteError:
|
|
1941
|
+
# APSW releases the completed statement before exposing its
|
|
1942
|
+
# description when a valid SELECT produces zero rows. The
|
|
1943
|
+
# exact same statement was prepared, authorised, and bounded
|
|
1944
|
+
# immediately above by query_info(), so its validated
|
|
1945
|
+
# description remains the authoritative zero-row metadata.
|
|
1946
|
+
execution_description = details.description
|
|
1947
|
+
if execution_description != details.description:
|
|
1948
|
+
raise TrustedLiveQueryError(
|
|
1949
|
+
"SQLite result metadata changed between validation and execution."
|
|
1950
|
+
)
|
|
1951
|
+
retained_rows: list[tuple[Any, ...]] = []
|
|
1952
|
+
for row in cursor:
|
|
1953
|
+
self._raise_if_operation_aborted(control)
|
|
1954
|
+
retained_row = result_budget.retain_row(
|
|
1955
|
+
row,
|
|
1956
|
+
column_count=len(columns),
|
|
1957
|
+
retained_row_count=len(retained_rows),
|
|
1958
|
+
)
|
|
1959
|
+
retained_rows.append(retained_row)
|
|
1960
|
+
result = TrustedQueryResult(columns=columns, rows=tuple(retained_rows))
|
|
1961
|
+
except apsw.Error as error:
|
|
1962
|
+
body_error = self._translate_sqlite_error(
|
|
1963
|
+
error,
|
|
1964
|
+
native_sequence=control.native_sequence,
|
|
1965
|
+
control=control,
|
|
1966
|
+
)
|
|
1967
|
+
body_cause = error
|
|
1968
|
+
body_traceback = body_error.__traceback__
|
|
1969
|
+
except BaseException as error:
|
|
1970
|
+
body_error = error
|
|
1971
|
+
body_traceback = error.__traceback__
|
|
1972
|
+
finally:
|
|
1973
|
+
if cursor is not None:
|
|
1974
|
+
try:
|
|
1975
|
+
cursor.close()
|
|
1976
|
+
except BaseException as close_error:
|
|
1977
|
+
operation_cleanup_errors.append(close_error)
|
|
1978
|
+
if body_error is None:
|
|
1979
|
+
body_error = close_error
|
|
1980
|
+
body_traceback = close_error.__traceback__
|
|
1981
|
+
else:
|
|
1982
|
+
body_error.add_note(
|
|
1983
|
+
f"SQLite cursor cleanup also failed: {close_error!r}"
|
|
1984
|
+
)
|
|
1985
|
+
if statement_length_limit is not None:
|
|
1986
|
+
restore_error = self._restore_statement_result_length_limit(
|
|
1987
|
+
connection,
|
|
1988
|
+
control,
|
|
1989
|
+
statement_length_limit,
|
|
1990
|
+
)
|
|
1991
|
+
if restore_error is not None:
|
|
1992
|
+
operation_cleanup_errors.append(
|
|
1993
|
+
TrustedLiveCleanupError(
|
|
1994
|
+
"Per-statement SQLite result-limit restoration was "
|
|
1995
|
+
"not proved safe; the reader connection must be retired."
|
|
1996
|
+
)
|
|
1997
|
+
)
|
|
1998
|
+
if body_error is None:
|
|
1999
|
+
body_error = restore_error
|
|
2000
|
+
body_traceback = restore_error.__traceback__
|
|
2001
|
+
else:
|
|
2002
|
+
body_error.add_note(
|
|
2003
|
+
"SQLite statement-limit cleanup also failed: "
|
|
2004
|
+
f"{restore_error!r}"
|
|
2005
|
+
)
|
|
2006
|
+
if body_error is None:
|
|
2007
|
+
try:
|
|
2008
|
+
self._raise_if_operation_aborted(control)
|
|
2009
|
+
except BaseException as error:
|
|
2010
|
+
body_error = error
|
|
2011
|
+
body_traceback = error.__traceback__
|
|
2012
|
+
if body_error is not None:
|
|
2013
|
+
if body_cause is not None:
|
|
2014
|
+
raise body_error.with_traceback(body_traceback) from body_cause
|
|
2015
|
+
raise body_error.with_traceback(body_traceback)
|
|
2016
|
+
if result is None:
|
|
2017
|
+
raise TrustedLiveQueryError(
|
|
2018
|
+
"The trusted query completed without a materialised result."
|
|
2019
|
+
)
|
|
2020
|
+
return result
|
|
2021
|
+
|
|
2022
|
+
def _bootstrap_scalar(
|
|
2023
|
+
self,
|
|
2024
|
+
sql: str,
|
|
2025
|
+
*,
|
|
2026
|
+
bootstrap: apsw.Connection | None = None,
|
|
2027
|
+
) -> Any:
|
|
2028
|
+
if bootstrap is None:
|
|
2029
|
+
bootstrap = self._bootstrap
|
|
2030
|
+
if bootstrap is None:
|
|
2031
|
+
raise TrustedLiveReaderUnavailableError(
|
|
2032
|
+
"The trusted live reader's native control channel is closed."
|
|
2033
|
+
)
|
|
2034
|
+
cursor: apsw.Cursor | None = None
|
|
2035
|
+
body_error: BaseException | None = None
|
|
2036
|
+
body_traceback: TracebackType | None = None
|
|
2037
|
+
value: Any = None
|
|
2038
|
+
try:
|
|
2039
|
+
cursor = bootstrap.cursor()
|
|
2040
|
+
row = cursor.execute(sql, (self._token_string,)).fetchone()
|
|
2041
|
+
if row is None or len(row) != 1:
|
|
2042
|
+
raise TrustedLiveReaderUnavailableError(
|
|
2043
|
+
"The native trusted VFS returned an invalid control result."
|
|
2044
|
+
)
|
|
2045
|
+
value = row[0]
|
|
2046
|
+
except BaseException as error:
|
|
2047
|
+
body_error = error
|
|
2048
|
+
body_traceback = error.__traceback__
|
|
2049
|
+
finally:
|
|
2050
|
+
if cursor is not None:
|
|
2051
|
+
try:
|
|
2052
|
+
cursor.close()
|
|
2053
|
+
except BaseException as close_error:
|
|
2054
|
+
if body_error is None:
|
|
2055
|
+
body_error = close_error
|
|
2056
|
+
body_traceback = close_error.__traceback__
|
|
2057
|
+
else:
|
|
2058
|
+
body_error.add_note(
|
|
2059
|
+
f"Native control cursor cleanup also failed: "
|
|
2060
|
+
f"{close_error!r}"
|
|
2061
|
+
)
|
|
2062
|
+
if body_error is not None:
|
|
2063
|
+
raise body_error.with_traceback(body_traceback)
|
|
2064
|
+
return value
|
|
2065
|
+
|
|
2066
|
+
def _read_native_status(self) -> _TrustedNativeStatus:
|
|
2067
|
+
raw_status = self._bootstrap_scalar("SELECT qplot_trusted_vfs_status(?)")
|
|
2068
|
+
if not isinstance(raw_status, str):
|
|
2069
|
+
raise TrustedLiveReaderUnavailableError(
|
|
2070
|
+
"The native trusted VFS returned a non-text status."
|
|
2071
|
+
)
|
|
2072
|
+
try:
|
|
2073
|
+
parsed = json.loads(raw_status)
|
|
2074
|
+
except json.JSONDecodeError as error:
|
|
2075
|
+
raise TrustedLiveReaderUnavailableError(
|
|
2076
|
+
"The native trusted VFS returned malformed status JSON."
|
|
2077
|
+
) from error
|
|
2078
|
+
expected_keys = {
|
|
2079
|
+
"sequence",
|
|
2080
|
+
"kind",
|
|
2081
|
+
"artifact",
|
|
2082
|
+
"operation",
|
|
2083
|
+
"sqlite_code",
|
|
2084
|
+
}
|
|
2085
|
+
if (
|
|
2086
|
+
not isinstance(parsed, dict)
|
|
2087
|
+
or set(parsed) != expected_keys
|
|
2088
|
+
or type(parsed["sequence"]) is not int
|
|
2089
|
+
or parsed["sequence"] < 0
|
|
2090
|
+
or parsed["kind"]
|
|
2091
|
+
not in {"none", "source_changed", "unsupported", "policy", "io"}
|
|
2092
|
+
or parsed["artifact"]
|
|
2093
|
+
not in {"none", "main", "wal", "shm", "journal", "temp"}
|
|
2094
|
+
or not isinstance(parsed["operation"], str)
|
|
2095
|
+
or type(parsed["sqlite_code"]) is not int
|
|
2096
|
+
or parsed["sqlite_code"] < 0
|
|
2097
|
+
):
|
|
2098
|
+
raise TrustedLiveReaderUnavailableError(
|
|
2099
|
+
"The native trusted VFS returned an invalid status record."
|
|
2100
|
+
)
|
|
2101
|
+
return _TrustedNativeStatus(
|
|
2102
|
+
sequence=parsed["sequence"],
|
|
2103
|
+
kind=parsed["kind"],
|
|
2104
|
+
artifact=parsed["artifact"],
|
|
2105
|
+
operation=parsed["operation"],
|
|
2106
|
+
sqlite_code=parsed["sqlite_code"],
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
def _native_status_if_available(self) -> _TrustedNativeStatus | None:
|
|
2110
|
+
if not self._native_extension_loaded or self._bootstrap is None:
|
|
2111
|
+
return None
|
|
2112
|
+
try:
|
|
2113
|
+
return self._read_native_status()
|
|
2114
|
+
except (apsw.Error, TrustedLiveReaderError):
|
|
2115
|
+
return None
|
|
2116
|
+
|
|
2117
|
+
def _validate_native_source(self, *, begin_reads: bool = False) -> None:
|
|
2118
|
+
# Validate all paths/handles, then reuse only pathname proofs during
|
|
2119
|
+
# this finite operation. _finish_operation always disables reuse and
|
|
2120
|
+
# revalidates before publication, including cancellation/error paths.
|
|
2121
|
+
function = "qplot_trusted_vfs_begin_reads" if begin_reads else "qplot_trusted_vfs_validate"
|
|
2122
|
+
result = self._bootstrap_scalar(f"SELECT {function}(?)")
|
|
2123
|
+
if result != 1:
|
|
2124
|
+
raise TrustedLiveReaderUnavailableError(
|
|
2125
|
+
"The native trusted VFS returned an invalid validation result."
|
|
2126
|
+
)
|
|
2127
|
+
|
|
2128
|
+
def _error_from_native_status(
|
|
2129
|
+
self,
|
|
2130
|
+
status: _TrustedNativeStatus,
|
|
2131
|
+
) -> TrustedLiveReaderError | None:
|
|
2132
|
+
location = (
|
|
2133
|
+
f" ({status.artifact}/{status.operation}, SQLite code {status.sqlite_code})"
|
|
2134
|
+
)
|
|
2135
|
+
if status.kind == "source_changed":
|
|
2136
|
+
self._invalidated = True
|
|
2137
|
+
return TrustedLiveSourceChangedError(
|
|
2138
|
+
"The native trusted VFS proved that a source file was "
|
|
2139
|
+
f"replaced or changed identity{location}."
|
|
2140
|
+
)
|
|
2141
|
+
if status.kind == "unsupported":
|
|
2142
|
+
return TrustedLiveUnsupportedSourceError(
|
|
2143
|
+
f"The native trusted VFS rejected the source{location}."
|
|
2144
|
+
)
|
|
2145
|
+
if status.kind == "policy":
|
|
2146
|
+
return TrustedLiveSqlRejectedError(
|
|
2147
|
+
f"The native trusted VFS rejected a prohibited operation{location}."
|
|
2148
|
+
)
|
|
2149
|
+
if status.kind == "io":
|
|
2150
|
+
return TrustedLiveSourceIOError(
|
|
2151
|
+
f"The native trusted VFS reported a source I/O failure{location}."
|
|
2152
|
+
)
|
|
2153
|
+
return None
|
|
2154
|
+
|
|
2155
|
+
def _translate_sqlite_error(
|
|
2156
|
+
self,
|
|
2157
|
+
error: apsw.Error,
|
|
2158
|
+
*,
|
|
2159
|
+
native_sequence: int | None,
|
|
2160
|
+
control: _TrustedOperationControl | None = None,
|
|
2161
|
+
) -> TrustedLiveReaderError:
|
|
2162
|
+
status = self._native_status_if_available()
|
|
2163
|
+
status_is_new = status is not None and (
|
|
2164
|
+
native_sequence is None or status.sequence != native_sequence
|
|
2165
|
+
)
|
|
2166
|
+
if status_is_new and status is not None:
|
|
2167
|
+
status_error = self._error_from_native_status(status)
|
|
2168
|
+
if status_error is not None:
|
|
2169
|
+
return status_error
|
|
2170
|
+
|
|
2171
|
+
if control is not None:
|
|
2172
|
+
cancel_event = control.cancel_event
|
|
2173
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
2174
|
+
self._mark_operation_abort(control, "cancelled")
|
|
2175
|
+
if time.monotonic() >= control.deadline:
|
|
2176
|
+
self._mark_operation_abort(control, "deadline")
|
|
2177
|
+
reason = self._operation_abort_reason(control)
|
|
2178
|
+
if reason == "cancelled":
|
|
2179
|
+
return TrustedLiveCancelledError(
|
|
2180
|
+
"The trusted database read was cancelled."
|
|
2181
|
+
)
|
|
2182
|
+
if reason == "deadline":
|
|
2183
|
+
return TrustedLiveDeadlineExceededError(
|
|
2184
|
+
"The trusted database read deadline was exceeded."
|
|
2185
|
+
)
|
|
2186
|
+
policy_denied = control.policy_denied
|
|
2187
|
+
else:
|
|
2188
|
+
policy_denied = False
|
|
2189
|
+
|
|
2190
|
+
if isinstance(error, (apsw.BusyError, apsw.LockedError)):
|
|
2191
|
+
return TrustedLiveBusyTimeoutError(
|
|
2192
|
+
f"The trusted database remained busy beyond its bounded wait: {error}"
|
|
2193
|
+
)
|
|
2194
|
+
if isinstance(error, apsw.InterruptError):
|
|
2195
|
+
return TrustedLiveQueryError(
|
|
2196
|
+
"SQLite interrupted the trusted query without a matching "
|
|
2197
|
+
"cancellation or deadline request."
|
|
2198
|
+
)
|
|
2199
|
+
if isinstance(error, (apsw.CorruptError, apsw.NotADBError, apsw.FormatError)):
|
|
2200
|
+
return TrustedLiveInvalidDatabaseError(
|
|
2201
|
+
f"The selected database is corrupt or invalid: {error}"
|
|
2202
|
+
)
|
|
2203
|
+
if isinstance(error, apsw.TooBigError) and (
|
|
2204
|
+
control is not None and control.result_length_limit is not None
|
|
2205
|
+
):
|
|
2206
|
+
return TrustedLiveResultLimitError(
|
|
2207
|
+
"SQLite stopped producing a text or blob result at qPlot's "
|
|
2208
|
+
f"{control.result_length_limit}-byte runtime length limit."
|
|
2209
|
+
)
|
|
2210
|
+
if policy_denied:
|
|
2211
|
+
return TrustedLiveSqlRejectedError(
|
|
2212
|
+
f"The trusted reader rejected the SQL statement: {error}"
|
|
2213
|
+
)
|
|
2214
|
+
if isinstance(error, apsw.ReadOnlyError):
|
|
2215
|
+
return TrustedLiveSourceIOError(
|
|
2216
|
+
"SQLite's physically read-only source boundary could not "
|
|
2217
|
+
f"complete required coordination: {error}"
|
|
2218
|
+
)
|
|
2219
|
+
if isinstance(error, apsw.PermissionsError):
|
|
2220
|
+
return TrustedLiveSourceIOError(
|
|
2221
|
+
f"SQLite could not access the trusted source: {error}"
|
|
2222
|
+
)
|
|
2223
|
+
if isinstance(
|
|
2224
|
+
error,
|
|
2225
|
+
(
|
|
2226
|
+
apsw.CantOpenError,
|
|
2227
|
+
apsw.NoLFSError,
|
|
2228
|
+
apsw.VFSNotImplementedError,
|
|
2229
|
+
),
|
|
2230
|
+
):
|
|
2231
|
+
return TrustedLiveUnsupportedSourceError(
|
|
2232
|
+
f"The selected source or platform is unsupported: {error}"
|
|
2233
|
+
)
|
|
2234
|
+
if isinstance(error, (apsw.IOError, apsw.FullError)):
|
|
2235
|
+
return TrustedLiveSourceIOError(
|
|
2236
|
+
f"SQLite could not read the trusted source: {error}"
|
|
2237
|
+
)
|
|
2238
|
+
if isinstance(
|
|
2239
|
+
error,
|
|
2240
|
+
(
|
|
2241
|
+
apsw.BindingsError,
|
|
2242
|
+
apsw.MismatchError,
|
|
2243
|
+
apsw.RangeError,
|
|
2244
|
+
apsw.SchemaChangeError,
|
|
2245
|
+
apsw.SQLError,
|
|
2246
|
+
),
|
|
2247
|
+
):
|
|
2248
|
+
return TrustedLiveQueryError(f"The trusted read-only query failed: {error}")
|
|
2249
|
+
if control is not None:
|
|
2250
|
+
return TrustedLiveQueryError(
|
|
2251
|
+
f"SQLite failed while executing the trusted query: {error}"
|
|
2252
|
+
)
|
|
2253
|
+
return TrustedLiveReaderUnavailableError(
|
|
2254
|
+
f"SQLite failed while establishing the trusted reader boundary: {error}"
|
|
2255
|
+
)
|
|
2256
|
+
|
|
2257
|
+
def _finish_operation(
|
|
2258
|
+
self,
|
|
2259
|
+
connection: apsw.Connection,
|
|
2260
|
+
control: _TrustedOperationControl,
|
|
2261
|
+
*,
|
|
2262
|
+
transaction_started: bool,
|
|
2263
|
+
handlers_installed: bool,
|
|
2264
|
+
publication_pending: bool,
|
|
2265
|
+
prior_cleanup_errors: Sequence[BaseException] = (),
|
|
2266
|
+
) -> TrustedLiveReaderError | None:
|
|
2267
|
+
with self._operation_mutex:
|
|
2268
|
+
if self._active_operation is control:
|
|
2269
|
+
self._active_operation = None
|
|
2270
|
+
|
|
2271
|
+
cleanup_errors = list(prior_cleanup_errors)
|
|
2272
|
+
if control.blob_cleanup_error is not None:
|
|
2273
|
+
cleanup_errors.append(control.blob_cleanup_error)
|
|
2274
|
+
if handlers_installed:
|
|
2275
|
+
cleanup_errors.extend(self._remove_operation_handlers(connection, control))
|
|
2276
|
+
if transaction_started:
|
|
2277
|
+
try:
|
|
2278
|
+
# A fatal VFS/SQLite error can roll the transaction back before
|
|
2279
|
+
# Python reaches this cleanup path. Treat that already-clean
|
|
2280
|
+
# state as success; issuing ROLLBACK then would create a false
|
|
2281
|
+
# "no transaction is active" cleanup failure and hide the
|
|
2282
|
+
# identity error that caused the automatic rollback.
|
|
2283
|
+
if connection.in_transaction:
|
|
2284
|
+
self._execute_transaction_control("ROLLBACK")
|
|
2285
|
+
except BaseException as error:
|
|
2286
|
+
cleanup_errors.append(error)
|
|
2287
|
+
try:
|
|
2288
|
+
transaction_state = connection.txn_state("main")
|
|
2289
|
+
except BaseException as error:
|
|
2290
|
+
cleanup_errors.append(error)
|
|
2291
|
+
else:
|
|
2292
|
+
if transaction_state != apsw.SQLITE_TXN_NONE:
|
|
2293
|
+
cleanup_errors.append(
|
|
2294
|
+
TrustedLiveCleanupError(
|
|
2295
|
+
"SQLite retained a transaction after trusted-reader rollback."
|
|
2296
|
+
)
|
|
2297
|
+
)
|
|
2298
|
+
try:
|
|
2299
|
+
self._validate_native_source()
|
|
2300
|
+
post_status = self._read_native_status()
|
|
2301
|
+
if publication_pending and post_status.sequence != control.native_sequence:
|
|
2302
|
+
status_error = self._error_from_native_status(post_status)
|
|
2303
|
+
if status_error is not None:
|
|
2304
|
+
cleanup_errors.append(status_error)
|
|
2305
|
+
except apsw.Error as error:
|
|
2306
|
+
cleanup_errors.append(
|
|
2307
|
+
self._translate_sqlite_error(
|
|
2308
|
+
error,
|
|
2309
|
+
native_sequence=control.native_sequence,
|
|
2310
|
+
control=control,
|
|
2311
|
+
)
|
|
2312
|
+
)
|
|
2313
|
+
except BaseException as error:
|
|
2314
|
+
cleanup_errors.append(error)
|
|
2315
|
+
|
|
2316
|
+
if not cleanup_errors and publication_pending:
|
|
2317
|
+
if control.cancel_event is not None and control.cancel_event.is_set():
|
|
2318
|
+
return TrustedLiveCancelledError(
|
|
2319
|
+
"The trusted database read was cancelled before publication."
|
|
2320
|
+
)
|
|
2321
|
+
if time.monotonic() >= control.deadline:
|
|
2322
|
+
return TrustedLiveDeadlineExceededError(
|
|
2323
|
+
"The trusted database read deadline expired before publication."
|
|
2324
|
+
)
|
|
2325
|
+
if not cleanup_errors:
|
|
2326
|
+
return None
|
|
2327
|
+
|
|
2328
|
+
if any(
|
|
2329
|
+
isinstance(error, TrustedLiveSourceChangedError) for error in cleanup_errors
|
|
2330
|
+
):
|
|
2331
|
+
self._invalidated = True
|
|
2332
|
+
self._closed = True
|
|
2333
|
+
close_cleanup_error = self._close_resources(force=True)
|
|
2334
|
+
if close_cleanup_error is not None:
|
|
2335
|
+
for preceding_error in cleanup_errors:
|
|
2336
|
+
close_cleanup_error.add_note(
|
|
2337
|
+
"Failure that required forced reader cleanup: "
|
|
2338
|
+
f"{type(preceding_error).__name__}: {preceding_error}"
|
|
2339
|
+
)
|
|
2340
|
+
return close_cleanup_error
|
|
2341
|
+
primary = cleanup_errors[0]
|
|
2342
|
+
if isinstance(primary, TrustedLiveReaderError):
|
|
2343
|
+
cleanup_error = primary
|
|
2344
|
+
else:
|
|
2345
|
+
cleanup_error = TrustedLiveCleanupError(
|
|
2346
|
+
f"The trusted reader could not prove transaction cleanup: {primary}"
|
|
2347
|
+
)
|
|
2348
|
+
for additional_error in cleanup_errors[1:]:
|
|
2349
|
+
cleanup_error.add_note(f"Additional cleanup failure: {additional_error!r}")
|
|
2350
|
+
return cleanup_error
|
|
2351
|
+
|
|
2352
|
+
def query_batch(
|
|
2353
|
+
self,
|
|
2354
|
+
queries: Sequence[TrustedQuery],
|
|
2355
|
+
*,
|
|
2356
|
+
timeout: float | None = None,
|
|
2357
|
+
deadline: float | None = None,
|
|
2358
|
+
cancel_event: threading.Event | None = None,
|
|
2359
|
+
) -> tuple[TrustedQueryResult, ...]:
|
|
2360
|
+
"""Run a finite query batch in one repeatable-read transaction.
|
|
2361
|
+
|
|
2362
|
+
The supplied sequence is copied before ``BEGIN``. Every item must be a
|
|
2363
|
+
:class:`TrustedQuery`; no callback or cursor escapes while SQLite holds
|
|
2364
|
+
read locks. The effective deadline is the earliest of ``timeout`` and
|
|
2365
|
+
``deadline``. If neither is supplied, the reader's finite default is
|
|
2366
|
+
used.
|
|
2367
|
+
"""
|
|
2368
|
+
|
|
2369
|
+
specifications = tuple(queries)
|
|
2370
|
+
if not specifications:
|
|
2371
|
+
raise TrustedLiveSqlRejectedError(
|
|
2372
|
+
"A trusted query batch must contain at least one statement."
|
|
2373
|
+
)
|
|
2374
|
+
if len(specifications) > TRUSTED_LIVE_MAX_BATCH_QUERIES:
|
|
2375
|
+
raise TrustedLiveResultLimitError(
|
|
2376
|
+
"A trusted query batch contains more than "
|
|
2377
|
+
f"{TRUSTED_LIVE_MAX_BATCH_QUERIES} statements."
|
|
2378
|
+
)
|
|
2379
|
+
if not all(isinstance(query, TrustedQuery) for query in specifications):
|
|
2380
|
+
raise TypeError("query_batch accepts only TrustedQuery specifications.")
|
|
2381
|
+
|
|
2382
|
+
result_budget = _TrustedResultBudget.for_query_batch()
|
|
2383
|
+
connection, control = self._start_operation(
|
|
2384
|
+
timeout=timeout,
|
|
2385
|
+
deadline=deadline,
|
|
2386
|
+
cancel_event=cancel_event,
|
|
2387
|
+
)
|
|
2388
|
+
result_budget.abort_check = lambda: self._raise_if_operation_aborted(control)
|
|
2389
|
+
transaction_started = False
|
|
2390
|
+
handlers_installed = False
|
|
2391
|
+
body_error: BaseException | None = None
|
|
2392
|
+
body_cause: BaseException | None = None
|
|
2393
|
+
body_traceback: TracebackType | None = None
|
|
2394
|
+
results: tuple[TrustedQueryResult, ...] | None = None
|
|
2395
|
+
result_length_limit: _TrustedSqliteLengthLimit | None = None
|
|
2396
|
+
operation_cleanup_errors: list[BaseException] = []
|
|
2397
|
+
try:
|
|
2398
|
+
self._raise_if_operation_aborted(control)
|
|
2399
|
+
status = self._read_native_status()
|
|
2400
|
+
control.native_sequence = status.sequence
|
|
2401
|
+
self._validate_native_source(begin_reads=True)
|
|
2402
|
+
self._raise_if_operation_aborted(control)
|
|
2403
|
+
handlers_installed = True
|
|
2404
|
+
self._install_operation_handlers(connection, control)
|
|
2405
|
+
try:
|
|
2406
|
+
result_length_limit = self._install_result_length_limit(
|
|
2407
|
+
connection,
|
|
2408
|
+
control,
|
|
2409
|
+
)
|
|
2410
|
+
except TrustedLiveCleanupError as error:
|
|
2411
|
+
operation_cleanup_errors.append(
|
|
2412
|
+
TrustedLiveCleanupError(
|
|
2413
|
+
"SQLite result-limit setup was not proved safe; the "
|
|
2414
|
+
"reader connection must be retired."
|
|
2415
|
+
)
|
|
2416
|
+
)
|
|
2417
|
+
raise error
|
|
2418
|
+
self._raise_if_operation_aborted(control)
|
|
2419
|
+
try:
|
|
2420
|
+
self._execute_transaction_control("BEGIN")
|
|
2421
|
+
finally:
|
|
2422
|
+
# If BEGIN itself completed but explicit cursor cleanup failed,
|
|
2423
|
+
# this still forces rollback on the exceptional path.
|
|
2424
|
+
transaction_started = connection.in_transaction
|
|
2425
|
+
materialised: list[TrustedQueryResult] = []
|
|
2426
|
+
for query in specifications:
|
|
2427
|
+
self._raise_if_operation_aborted(control)
|
|
2428
|
+
materialised.append(
|
|
2429
|
+
self._query_spec_in_transaction(
|
|
2430
|
+
connection,
|
|
2431
|
+
query,
|
|
2432
|
+
result_budget,
|
|
2433
|
+
control,
|
|
2434
|
+
operation_cleanup_errors,
|
|
2435
|
+
)
|
|
2436
|
+
)
|
|
2437
|
+
self._raise_if_operation_aborted(control)
|
|
2438
|
+
results = tuple(materialised)
|
|
2439
|
+
except apsw.Error as error:
|
|
2440
|
+
body_error = self._translate_sqlite_error(
|
|
2441
|
+
error,
|
|
2442
|
+
native_sequence=control.native_sequence,
|
|
2443
|
+
control=control,
|
|
2444
|
+
)
|
|
2445
|
+
body_cause = error
|
|
2446
|
+
body_traceback = body_error.__traceback__
|
|
2447
|
+
except BaseException as error:
|
|
2448
|
+
body_error = error
|
|
2449
|
+
body_traceback = error.__traceback__
|
|
2450
|
+
finally:
|
|
2451
|
+
if result_length_limit is not None:
|
|
2452
|
+
restore_error = self._restore_result_length_limit(
|
|
2453
|
+
connection,
|
|
2454
|
+
control,
|
|
2455
|
+
result_length_limit,
|
|
2456
|
+
)
|
|
2457
|
+
if restore_error is not None:
|
|
2458
|
+
operation_cleanup_errors.append(restore_error)
|
|
2459
|
+
|
|
2460
|
+
cleanup_error = self._finish_operation(
|
|
2461
|
+
connection,
|
|
2462
|
+
control,
|
|
2463
|
+
transaction_started=transaction_started,
|
|
2464
|
+
handlers_installed=handlers_installed,
|
|
2465
|
+
publication_pending=body_error is None,
|
|
2466
|
+
prior_cleanup_errors=operation_cleanup_errors,
|
|
2467
|
+
)
|
|
2468
|
+
if body_error is not None:
|
|
2469
|
+
if cleanup_error is not None:
|
|
2470
|
+
if isinstance(cleanup_error, TrustedLiveCleanupError) or self._closed:
|
|
2471
|
+
preserved_body_error = body_error.with_traceback(body_traceback)
|
|
2472
|
+
if body_cause is not None:
|
|
2473
|
+
preserved_body_error.__cause__ = body_cause
|
|
2474
|
+
raise cleanup_error from preserved_body_error
|
|
2475
|
+
body_error.add_note(
|
|
2476
|
+
f"Trusted-reader cleanup also failed: {cleanup_error!r}"
|
|
2477
|
+
)
|
|
2478
|
+
if body_cause is not None:
|
|
2479
|
+
raise body_error.with_traceback(body_traceback) from body_cause
|
|
2480
|
+
raise body_error.with_traceback(body_traceback)
|
|
2481
|
+
if cleanup_error is not None:
|
|
2482
|
+
raise cleanup_error
|
|
2483
|
+
if results is None:
|
|
2484
|
+
raise TrustedLiveQueryError(
|
|
2485
|
+
"The trusted query batch completed without results."
|
|
2486
|
+
)
|
|
2487
|
+
return results
|
|
2488
|
+
|
|
2489
|
+
def query(
|
|
2490
|
+
self,
|
|
2491
|
+
sql: str,
|
|
2492
|
+
bindings: SqliteBindings = None,
|
|
2493
|
+
*,
|
|
2494
|
+
timeout: float | None = None,
|
|
2495
|
+
deadline: float | None = None,
|
|
2496
|
+
cancel_event: threading.Event | None = None,
|
|
2497
|
+
) -> TrustedQueryResult:
|
|
2498
|
+
"""Run one materialised statement in a bounded read transaction."""
|
|
2499
|
+
|
|
2500
|
+
return self.query_batch(
|
|
2501
|
+
(TrustedQuery(sql, bindings),),
|
|
2502
|
+
timeout=timeout,
|
|
2503
|
+
deadline=deadline,
|
|
2504
|
+
cancel_event=cancel_event,
|
|
2505
|
+
)[0]
|
|
2506
|
+
|
|
2507
|
+
def data_version(
|
|
2508
|
+
self,
|
|
2509
|
+
*,
|
|
2510
|
+
timeout: float | None = None,
|
|
2511
|
+
deadline: float | None = None,
|
|
2512
|
+
cancel_event: threading.Event | None = None,
|
|
2513
|
+
) -> int:
|
|
2514
|
+
"""Return ``PRAGMA data_version`` in its own finite transaction."""
|
|
2515
|
+
|
|
2516
|
+
result = self.query(
|
|
2517
|
+
"PRAGMA data_version",
|
|
2518
|
+
timeout=timeout,
|
|
2519
|
+
deadline=deadline,
|
|
2520
|
+
cancel_event=cancel_event,
|
|
2521
|
+
)
|
|
2522
|
+
if (
|
|
2523
|
+
result.columns != ("data_version",)
|
|
2524
|
+
or len(result.rows) != 1
|
|
2525
|
+
or len(result.rows[0]) != 1
|
|
2526
|
+
or not isinstance(result.rows[0][0], int)
|
|
2527
|
+
):
|
|
2528
|
+
raise TrustedLiveReaderError(
|
|
2529
|
+
"SQLite returned an invalid PRAGMA data_version result."
|
|
2530
|
+
)
|
|
2531
|
+
return result.rows[0][0]
|
|
2532
|
+
|
|
2533
|
+
def _read_native_audit(
|
|
2534
|
+
self,
|
|
2535
|
+
bootstrap: apsw.Connection,
|
|
2536
|
+
) -> TrustedVfsAudit:
|
|
2537
|
+
raw_audit = self._bootstrap_scalar(
|
|
2538
|
+
"SELECT qplot_trusted_vfs_audit(?)",
|
|
2539
|
+
bootstrap=bootstrap,
|
|
2540
|
+
)
|
|
2541
|
+
if not isinstance(raw_audit, str):
|
|
2542
|
+
raise TrustedLiveReaderError(
|
|
2543
|
+
"The native VFS returned an invalid audit result."
|
|
2544
|
+
)
|
|
2545
|
+
try:
|
|
2546
|
+
parsed = json.loads(raw_audit)
|
|
2547
|
+
except json.JSONDecodeError as error:
|
|
2548
|
+
raise TrustedLiveReaderError(
|
|
2549
|
+
"The native VFS returned malformed audit counters."
|
|
2550
|
+
) from error
|
|
2551
|
+
if not isinstance(parsed, dict) or not all(
|
|
2552
|
+
isinstance(key, str) and type(value) is int for key, value in parsed.items()
|
|
2553
|
+
):
|
|
2554
|
+
raise TrustedLiveReaderError(
|
|
2555
|
+
"The native VFS returned malformed audit counters."
|
|
2556
|
+
)
|
|
2557
|
+
return TrustedVfsAudit(MappingProxyType(parsed))
|
|
2558
|
+
|
|
2559
|
+
def audit(self) -> TrustedVfsAudit:
|
|
2560
|
+
"""Return native counters, including the final close-time snapshot."""
|
|
2561
|
+
self._check_thread()
|
|
2562
|
+
bootstrap = self._bootstrap
|
|
2563
|
+
if bootstrap is not None:
|
|
2564
|
+
return self._read_native_audit(bootstrap)
|
|
2565
|
+
if self._final_audit is not None:
|
|
2566
|
+
return self._final_audit
|
|
2567
|
+
raise TrustedLiveReaderClosedError(
|
|
2568
|
+
"The trusted live reader audit channel is closed."
|
|
2569
|
+
)
|
|
2570
|
+
|
|
2571
|
+
@staticmethod
|
|
2572
|
+
def _verify_final_audit(audit: TrustedVfsAudit) -> None:
|
|
2573
|
+
"""Require native proof and SHM state to be fully released."""
|
|
2574
|
+
|
|
2575
|
+
counters = audit.counters
|
|
2576
|
+
required = {
|
|
2577
|
+
"base_close_error",
|
|
2578
|
+
"proof_open",
|
|
2579
|
+
"proof_close",
|
|
2580
|
+
"proof_close_error",
|
|
2581
|
+
"proof_active",
|
|
2582
|
+
"shm_unmap_error",
|
|
2583
|
+
"shm_unmap_delete_forwarded",
|
|
2584
|
+
}
|
|
2585
|
+
missing = required.difference(counters)
|
|
2586
|
+
if missing:
|
|
2587
|
+
missing_names = ", ".join(sorted(missing))
|
|
2588
|
+
raise TrustedLiveCleanupError(
|
|
2589
|
+
f"The native VFS omitted required cleanup counters: {missing_names}."
|
|
2590
|
+
)
|
|
2591
|
+
failures: list[str] = []
|
|
2592
|
+
if counters["proof_active"] != 0:
|
|
2593
|
+
failures.append(f"proof_active={counters['proof_active']}")
|
|
2594
|
+
if counters["proof_open"] != counters["proof_close"]:
|
|
2595
|
+
failures.append(
|
|
2596
|
+
f"proof_open={counters['proof_open']} but "
|
|
2597
|
+
f"proof_close={counters['proof_close']}"
|
|
2598
|
+
)
|
|
2599
|
+
for counter_name in (
|
|
2600
|
+
"base_close_error",
|
|
2601
|
+
"proof_close_error",
|
|
2602
|
+
"shm_unmap_error",
|
|
2603
|
+
"shm_unmap_delete_forwarded",
|
|
2604
|
+
):
|
|
2605
|
+
if counters[counter_name] != 0:
|
|
2606
|
+
failures.append(f"{counter_name}={counters[counter_name]}")
|
|
2607
|
+
if failures:
|
|
2608
|
+
raise TrustedLiveCleanupError(
|
|
2609
|
+
"The native VFS did not release trusted proof/SHM state: "
|
|
2610
|
+
+ ", ".join(failures)
|
|
2611
|
+
+ "."
|
|
2612
|
+
)
|
|
2613
|
+
|
|
2614
|
+
def _close_resources(self, *, force: bool) -> TrustedLiveCleanupError | None:
|
|
2615
|
+
if self._resource_cleanup_complete:
|
|
2616
|
+
cleanup_error = self._resource_cleanup_error
|
|
2617
|
+
if cleanup_error is not None and not force:
|
|
2618
|
+
raise cleanup_error
|
|
2619
|
+
return cleanup_error
|
|
2620
|
+
|
|
2621
|
+
errors: list[BaseException] = []
|
|
2622
|
+
if self._preflight_cleanup_error is not None:
|
|
2623
|
+
errors.append(self._preflight_cleanup_error)
|
|
2624
|
+
native_release_confirmed = (
|
|
2625
|
+
self._native_release_confirmed or not self._native_extension_loaded
|
|
2626
|
+
)
|
|
2627
|
+
try:
|
|
2628
|
+
connection, self._connection = self._connection, None
|
|
2629
|
+
if connection is not None:
|
|
2630
|
+
try:
|
|
2631
|
+
connection.close(False)
|
|
2632
|
+
except BaseException as error:
|
|
2633
|
+
errors.append(error)
|
|
2634
|
+
try:
|
|
2635
|
+
connection.close(True)
|
|
2636
|
+
except BaseException as force_close_error:
|
|
2637
|
+
errors.append(force_close_error)
|
|
2638
|
+
|
|
2639
|
+
bootstrap, self._bootstrap = self._bootstrap, None
|
|
2640
|
+
if bootstrap is not None:
|
|
2641
|
+
final_audit_captured = False
|
|
2642
|
+
final_audit_verified = False
|
|
2643
|
+
final_audit_error: BaseException | None = None
|
|
2644
|
+
if self._native_session_attempted:
|
|
2645
|
+
try:
|
|
2646
|
+
final_audit = self._read_native_audit(bootstrap)
|
|
2647
|
+
self._final_audit = final_audit
|
|
2648
|
+
final_audit_captured = True
|
|
2649
|
+
self._verify_final_audit(final_audit)
|
|
2650
|
+
final_audit_verified = True
|
|
2651
|
+
except BaseException as error:
|
|
2652
|
+
# xOpen can reject input before the native session is
|
|
2653
|
+
# configured. Defer this error until release reports
|
|
2654
|
+
# whether a matching session actually existed.
|
|
2655
|
+
final_audit_error = error
|
|
2656
|
+
if self._native_extension_loaded:
|
|
2657
|
+
cursor: apsw.Cursor | None = None
|
|
2658
|
+
release_result: int | None = None
|
|
2659
|
+
release_error: BaseException | None = None
|
|
2660
|
+
try:
|
|
2661
|
+
cursor = bootstrap.cursor()
|
|
2662
|
+
row = cursor.execute(
|
|
2663
|
+
"SELECT qplot_trusted_vfs_release(?)",
|
|
2664
|
+
(self._token_string,),
|
|
2665
|
+
).fetchone()
|
|
2666
|
+
if (
|
|
2667
|
+
row is None
|
|
2668
|
+
or len(row) != 1
|
|
2669
|
+
or type(row[0]) is not int
|
|
2670
|
+
or row[0] not in {0, 1}
|
|
2671
|
+
):
|
|
2672
|
+
raise TrustedLiveCleanupError(
|
|
2673
|
+
"The native VFS returned an invalid release result."
|
|
2674
|
+
)
|
|
2675
|
+
release_result = row[0]
|
|
2676
|
+
except BaseException as error:
|
|
2677
|
+
release_error = error
|
|
2678
|
+
finally:
|
|
2679
|
+
if cursor is not None:
|
|
2680
|
+
try:
|
|
2681
|
+
cursor.close()
|
|
2682
|
+
except BaseException as error:
|
|
2683
|
+
errors.append(error)
|
|
2684
|
+
if release_error is not None:
|
|
2685
|
+
if final_audit_error is not None:
|
|
2686
|
+
errors.append(final_audit_error)
|
|
2687
|
+
errors.append(release_error)
|
|
2688
|
+
elif release_result == 0:
|
|
2689
|
+
if final_audit_captured:
|
|
2690
|
+
errors.append(
|
|
2691
|
+
TrustedLiveCleanupError(
|
|
2692
|
+
"The native VFS reported no session after "
|
|
2693
|
+
"returning its audit."
|
|
2694
|
+
)
|
|
2695
|
+
)
|
|
2696
|
+
else:
|
|
2697
|
+
# A zero result authoritatively proves that xOpen
|
|
2698
|
+
# rejected the request before configuring native
|
|
2699
|
+
# state. Its expected unknown-token audit error is
|
|
2700
|
+
# therefore not cleanup uncertainty.
|
|
2701
|
+
native_release_confirmed = True
|
|
2702
|
+
self._native_release_confirmed = True
|
|
2703
|
+
elif release_result == 1:
|
|
2704
|
+
native_release_confirmed = True
|
|
2705
|
+
self._native_release_confirmed = True
|
|
2706
|
+
if not self._native_session_attempted:
|
|
2707
|
+
errors.append(
|
|
2708
|
+
TrustedLiveCleanupError(
|
|
2709
|
+
"The native VFS released an unexpected session."
|
|
2710
|
+
)
|
|
2711
|
+
)
|
|
2712
|
+
elif final_audit_error is not None:
|
|
2713
|
+
errors.append(final_audit_error)
|
|
2714
|
+
elif not final_audit_verified:
|
|
2715
|
+
errors.append(
|
|
2716
|
+
TrustedLiveCleanupError(
|
|
2717
|
+
"The native VFS session was released without "
|
|
2718
|
+
"a verified final audit."
|
|
2719
|
+
)
|
|
2720
|
+
)
|
|
2721
|
+
try:
|
|
2722
|
+
bootstrap.close(True)
|
|
2723
|
+
except BaseException as error:
|
|
2724
|
+
errors.append(error)
|
|
2725
|
+
|
|
2726
|
+
temporary_directory, self._temporary_directory = (
|
|
2727
|
+
self._temporary_directory,
|
|
2728
|
+
None,
|
|
2729
|
+
)
|
|
2730
|
+
self._native_temporary_path = None
|
|
2731
|
+
if temporary_directory is not None:
|
|
2732
|
+
try:
|
|
2733
|
+
temporary_directory.cleanup()
|
|
2734
|
+
except BaseException as error:
|
|
2735
|
+
errors.append(error)
|
|
2736
|
+
finally:
|
|
2737
|
+
if not native_release_confirmed and not errors:
|
|
2738
|
+
errors.append(
|
|
2739
|
+
TrustedLiveCleanupError(
|
|
2740
|
+
"The native trusted VFS release could not be confirmed."
|
|
2741
|
+
)
|
|
2742
|
+
)
|
|
2743
|
+
|
|
2744
|
+
if errors:
|
|
2745
|
+
cleanup_error = TrustedLiveCleanupError(
|
|
2746
|
+
"The trusted reader did not close cleanly; cleanup could not be "
|
|
2747
|
+
f"proved and this process is quarantined: {errors[0]}"
|
|
2748
|
+
)
|
|
2749
|
+
for additional_error in errors[1:]:
|
|
2750
|
+
cleanup_error.add_note(
|
|
2751
|
+
f"Additional close failure: {additional_error!r}"
|
|
2752
|
+
)
|
|
2753
|
+
_quarantine_process_session(
|
|
2754
|
+
self._process_session_owner,
|
|
2755
|
+
cleanup_error,
|
|
2756
|
+
)
|
|
2757
|
+
self._resource_cleanup_error = cleanup_error
|
|
2758
|
+
self._resource_cleanup_complete = True
|
|
2759
|
+
if force:
|
|
2760
|
+
return cleanup_error
|
|
2761
|
+
raise cleanup_error
|
|
2762
|
+
_release_process_session(self._process_session_owner)
|
|
2763
|
+
self._native_release_confirmed = native_release_confirmed
|
|
2764
|
+
self._resource_cleanup_complete = True
|
|
2765
|
+
return None
|
|
2766
|
+
|
|
2767
|
+
def close(self) -> None:
|
|
2768
|
+
"""Close the SQLite handles and remove the private temp directory."""
|
|
2769
|
+
self._check_thread()
|
|
2770
|
+
if self._closed:
|
|
2771
|
+
return
|
|
2772
|
+
with self._operation_mutex:
|
|
2773
|
+
if self._active_operation is not None:
|
|
2774
|
+
raise TrustedLiveTransactionError(
|
|
2775
|
+
"Interrupt and finish the active operation before closing "
|
|
2776
|
+
"its trusted reader."
|
|
2777
|
+
)
|
|
2778
|
+
self._closed = True
|
|
2779
|
+
self._close_resources(force=False)
|
|
2780
|
+
|
|
2781
|
+
def __enter__(self) -> TrustedLiveReader:
|
|
2782
|
+
self._require_open()
|
|
2783
|
+
return self
|
|
2784
|
+
|
|
2785
|
+
def __exit__(
|
|
2786
|
+
self,
|
|
2787
|
+
exception_type: type[BaseException] | None,
|
|
2788
|
+
exception: BaseException | None,
|
|
2789
|
+
traceback: TracebackType | None,
|
|
2790
|
+
) -> None:
|
|
2791
|
+
del exception_type, exception, traceback
|
|
2792
|
+
self.close()
|
|
2793
|
+
|
|
2794
|
+
def __del__(self) -> None:
|
|
2795
|
+
"""Best-effort release if an owner abandons the reader without closing."""
|
|
2796
|
+
try:
|
|
2797
|
+
if getattr(self, "_closed", True):
|
|
2798
|
+
return
|
|
2799
|
+
self._closed = True
|
|
2800
|
+
operation_mutex = getattr(self, "_operation_mutex", None)
|
|
2801
|
+
if operation_mutex is not None:
|
|
2802
|
+
with operation_mutex:
|
|
2803
|
+
self._active_operation = None
|
|
2804
|
+
self._close_resources(force=True)
|
|
2805
|
+
except BaseException:
|
|
2806
|
+
# Finalizers may run during interpreter teardown or after a partial
|
|
2807
|
+
# constructor failure. They must never make that teardown noisy.
|
|
2808
|
+
pass
|