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,1167 @@
|
|
|
1
|
+
"""Safe filename approval and publication for user-requested exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ntpath
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import unicodedata
|
|
11
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from PyQt6 import QtWidgets as qtw
|
|
16
|
+
|
|
17
|
+
from qplot.datahandling.file_identity import (
|
|
18
|
+
SQLITE_SIDECAR_SUFFIXES,
|
|
19
|
+
DatabaseFileIdentity,
|
|
20
|
+
canonical_database_path,
|
|
21
|
+
database_file_identity,
|
|
22
|
+
logical_database_path,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_DATABASE_ARTIFACT_SUFFIXES = ("", *SQLITE_SIDECAR_SUFFIXES)
|
|
26
|
+
_SQLITE_HEADER = b"SQLite format 3\x00"
|
|
27
|
+
_SQLITE_ARTIFACT_HEADERS = (
|
|
28
|
+
_SQLITE_HEADER,
|
|
29
|
+
b"\x37\x7f\x06\x82", # WAL, little-endian checksums
|
|
30
|
+
b"\x37\x7f\x06\x83", # WAL, big-endian checksums
|
|
31
|
+
b"\xd9\xd5\x05\xf9\x20\xa1\x63\xd7", # rollback journal
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UnsafeExportDestinationError(RuntimeError):
|
|
36
|
+
"""Raised when an export cannot safely use its selected destination."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ProtectedDatabaseFiles:
|
|
41
|
+
"""Filesystem names and instances owned by current or retained DB views."""
|
|
42
|
+
|
|
43
|
+
logical_paths: frozenset[str] = frozenset()
|
|
44
|
+
resolved_paths: frozenset[str] = frozenset()
|
|
45
|
+
identities: frozenset[DatabaseFileIdentity] = frozenset()
|
|
46
|
+
|
|
47
|
+
def merged(self, other: ProtectedDatabaseFiles) -> ProtectedDatabaseFiles:
|
|
48
|
+
"""Return the union of two protection observations."""
|
|
49
|
+
return ProtectedDatabaseFiles(
|
|
50
|
+
logical_paths=self.logical_paths | other.logical_paths,
|
|
51
|
+
resolved_paths=self.resolved_paths | other.resolved_paths,
|
|
52
|
+
identities=self.identities | other.identities,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ExportDestinationTransaction:
|
|
58
|
+
"""One exact export target approved against a stable filesystem view."""
|
|
59
|
+
|
|
60
|
+
filename: str
|
|
61
|
+
parent_path: str
|
|
62
|
+
parent_identity: tuple[int, ...]
|
|
63
|
+
target_signature: tuple[object, ...] | None
|
|
64
|
+
replacement_confirmed: bool
|
|
65
|
+
protected_database_files: ProtectedDatabaseFiles
|
|
66
|
+
owner: object | None = field(default=None, compare=False, repr=False)
|
|
67
|
+
existing_target_validator: Callable[[str], Any] | None = field(
|
|
68
|
+
default=None,
|
|
69
|
+
compare=False,
|
|
70
|
+
repr=False,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def target_existed(self) -> bool:
|
|
75
|
+
"""Return whether approval captured an existing target."""
|
|
76
|
+
return self.target_signature is not None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def protected_database_identities(self) -> frozenset[DatabaseFileIdentity]:
|
|
80
|
+
"""Compatibility view of approval-time protected file identities."""
|
|
81
|
+
return self.protected_database_files.identities
|
|
82
|
+
|
|
83
|
+
def __fspath__(self) -> str:
|
|
84
|
+
return self.filename
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
return self.filename
|
|
88
|
+
|
|
89
|
+
def validate(self, *, parent_fd: int | None = None) -> None:
|
|
90
|
+
"""Revalidate the exact approval without changing filesystem state."""
|
|
91
|
+
_ensure_parent_is_unchanged(self, parent_fd=parent_fd)
|
|
92
|
+
|
|
93
|
+
protected_files = self.protected_database_files.merged(
|
|
94
|
+
collect_protected_database_files(self.owner)
|
|
95
|
+
)
|
|
96
|
+
_ensure_target_is_not_protected(
|
|
97
|
+
self.filename, protected_files, parent_fd=parent_fd,
|
|
98
|
+
)
|
|
99
|
+
current_signature = _target_signature(
|
|
100
|
+
self.filename,
|
|
101
|
+
parent_fd=parent_fd,
|
|
102
|
+
)
|
|
103
|
+
if current_signature != self.target_signature:
|
|
104
|
+
raise UnsafeExportDestinationError(
|
|
105
|
+
"The selected file changed while printing or exporting; "
|
|
106
|
+
"it was not replaced."
|
|
107
|
+
)
|
|
108
|
+
if self.target_existed and not self.replacement_confirmed:
|
|
109
|
+
raise UnsafeExportDestinationError(
|
|
110
|
+
"Replacement of the existing export file was not confirmed."
|
|
111
|
+
)
|
|
112
|
+
_run_existing_target_validator(self, current_signature)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def normalize_export_path(filename: str, required_suffix: str) -> str:
|
|
116
|
+
"""Return an absolute export path with ``required_suffix`` exactly once."""
|
|
117
|
+
if not required_suffix.startswith("."):
|
|
118
|
+
required_suffix = f".{required_suffix}"
|
|
119
|
+
if not filename.casefold().endswith(required_suffix.casefold()):
|
|
120
|
+
filename = f"{filename}{required_suffix}"
|
|
121
|
+
return os.path.abspath(filename)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def choose_export_path(
|
|
125
|
+
parent: qtw.QWidget,
|
|
126
|
+
*,
|
|
127
|
+
caption: str,
|
|
128
|
+
suggested_path: str,
|
|
129
|
+
name_filter: str,
|
|
130
|
+
required_suffix: str,
|
|
131
|
+
replace_title: str,
|
|
132
|
+
file_description: str,
|
|
133
|
+
existing_target_validator: Callable[[str], Any] | None = None,
|
|
134
|
+
) -> ExportDestinationTransaction | None:
|
|
135
|
+
"""Choose, normalize, and approve the exact path that may be replaced."""
|
|
136
|
+
dialog_options = (
|
|
137
|
+
qtw.QFileDialog.Option.DontConfirmOverwrite
|
|
138
|
+
| qtw.QFileDialog.Option.DontUseNativeDialog
|
|
139
|
+
)
|
|
140
|
+
filename = qtw.QFileDialog.getSaveFileName(
|
|
141
|
+
parent,
|
|
142
|
+
caption,
|
|
143
|
+
suggested_path,
|
|
144
|
+
name_filter,
|
|
145
|
+
options=dialog_options,
|
|
146
|
+
)[0]
|
|
147
|
+
if not filename:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
filename = normalize_export_path(filename, required_suffix)
|
|
151
|
+
return approve_export_path(
|
|
152
|
+
parent,
|
|
153
|
+
filename,
|
|
154
|
+
replace_title=replace_title,
|
|
155
|
+
file_description=file_description,
|
|
156
|
+
existing_target_validator=existing_target_validator,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def choose_export_path_with_suffixes(
|
|
161
|
+
parent: qtw.QWidget,
|
|
162
|
+
*,
|
|
163
|
+
caption: str,
|
|
164
|
+
suggested_path: str,
|
|
165
|
+
name_filter: str,
|
|
166
|
+
allowed_suffixes: Iterable[str],
|
|
167
|
+
default_suffix: str,
|
|
168
|
+
replace_title: str,
|
|
169
|
+
file_description: str,
|
|
170
|
+
match_selected_filter: bool = False,
|
|
171
|
+
) -> ExportDestinationTransaction | None:
|
|
172
|
+
"""Choose and approve an export, optionally enforcing its selected format.
|
|
173
|
+
|
|
174
|
+
When matching the filter, replace a conflicting allowed suffix before
|
|
175
|
+
approval. An absent/unrecognized filter leaves an allowed suffix intact.
|
|
176
|
+
"""
|
|
177
|
+
suffixes = tuple(
|
|
178
|
+
suffix.casefold() if suffix.startswith(".") else f".{suffix.casefold()}"
|
|
179
|
+
for suffix in allowed_suffixes
|
|
180
|
+
)
|
|
181
|
+
if not suffixes:
|
|
182
|
+
raise ValueError("At least one export suffix must be allowed.")
|
|
183
|
+
default_suffix = (
|
|
184
|
+
default_suffix.casefold()
|
|
185
|
+
if default_suffix.startswith(".")
|
|
186
|
+
else f".{default_suffix.casefold()}"
|
|
187
|
+
)
|
|
188
|
+
if default_suffix not in suffixes:
|
|
189
|
+
raise ValueError("The default export suffix must also be allowed.")
|
|
190
|
+
|
|
191
|
+
dialog_options = (
|
|
192
|
+
qtw.QFileDialog.Option.DontConfirmOverwrite
|
|
193
|
+
| qtw.QFileDialog.Option.DontUseNativeDialog
|
|
194
|
+
)
|
|
195
|
+
filename, selected_filter = qtw.QFileDialog.getSaveFileName(
|
|
196
|
+
parent,
|
|
197
|
+
caption,
|
|
198
|
+
suggested_path,
|
|
199
|
+
name_filter,
|
|
200
|
+
options=dialog_options,
|
|
201
|
+
)
|
|
202
|
+
if not filename:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
selected_suffix = os.path.splitext(filename)[1].casefold()
|
|
206
|
+
selected_match = re.search(r"\*\.([A-Za-z0-9]+)\b", selected_filter)
|
|
207
|
+
filtered_suffix = (
|
|
208
|
+
f".{selected_match.group(1).casefold()}"
|
|
209
|
+
if selected_match is not None else None
|
|
210
|
+
)
|
|
211
|
+
if match_selected_filter and filtered_suffix in suffixes:
|
|
212
|
+
if selected_suffix in suffixes and selected_suffix != filtered_suffix:
|
|
213
|
+
filename = f"{os.path.splitext(filename)[0]}{filtered_suffix}"
|
|
214
|
+
selected_suffix = filtered_suffix
|
|
215
|
+
elif selected_suffix not in suffixes:
|
|
216
|
+
selected_suffix = filtered_suffix
|
|
217
|
+
filename = f"{filename}{selected_suffix}"
|
|
218
|
+
if selected_suffix not in suffixes:
|
|
219
|
+
if filtered_suffix in suffixes:
|
|
220
|
+
selected_suffix = filtered_suffix
|
|
221
|
+
if selected_suffix not in suffixes:
|
|
222
|
+
selected_suffix = default_suffix
|
|
223
|
+
filename = f"{filename}{selected_suffix}"
|
|
224
|
+
|
|
225
|
+
return approve_export_path(
|
|
226
|
+
parent,
|
|
227
|
+
os.path.abspath(filename),
|
|
228
|
+
replace_title=replace_title,
|
|
229
|
+
file_description=file_description,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def approve_export_path(
|
|
234
|
+
parent: qtw.QWidget,
|
|
235
|
+
filename: str,
|
|
236
|
+
*,
|
|
237
|
+
replace_title: str,
|
|
238
|
+
file_description: str,
|
|
239
|
+
existing_target_validator: Callable[[str], Any] | None = None,
|
|
240
|
+
) -> ExportDestinationTransaction | None:
|
|
241
|
+
"""Capture one selected path and explicitly confirm its exact target."""
|
|
242
|
+
destination = _capture_export_destination(
|
|
243
|
+
parent,
|
|
244
|
+
os.path.abspath(filename),
|
|
245
|
+
existing_target_validator=existing_target_validator,
|
|
246
|
+
)
|
|
247
|
+
if not destination.target_existed:
|
|
248
|
+
return destination
|
|
249
|
+
|
|
250
|
+
reply = qtw.QMessageBox.question(
|
|
251
|
+
parent,
|
|
252
|
+
replace_title,
|
|
253
|
+
f"{destination.filename} already exists.\n\n"
|
|
254
|
+
f"Replace the existing {file_description}?",
|
|
255
|
+
qtw.QMessageBox.StandardButton.Yes | qtw.QMessageBox.StandardButton.No,
|
|
256
|
+
qtw.QMessageBox.StandardButton.No,
|
|
257
|
+
)
|
|
258
|
+
if reply != qtw.QMessageBox.StandardButton.Yes:
|
|
259
|
+
return None
|
|
260
|
+
return replace(destination, replacement_confirmed=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def prepare_export_destination(
|
|
264
|
+
owner: object | None,
|
|
265
|
+
filename: str,
|
|
266
|
+
*,
|
|
267
|
+
replacement_confirmed: bool = False,
|
|
268
|
+
required_suffix: str | None = None,
|
|
269
|
+
existing_target_validator: Callable[[str], Any] | None = None,
|
|
270
|
+
) -> ExportDestinationTransaction:
|
|
271
|
+
"""Approve a caller-supplied path without supplying implicit consent."""
|
|
272
|
+
if required_suffix is not None:
|
|
273
|
+
filename = normalize_export_path(filename, required_suffix)
|
|
274
|
+
else:
|
|
275
|
+
filename = os.path.abspath(filename)
|
|
276
|
+
destination = _capture_export_destination(
|
|
277
|
+
owner,
|
|
278
|
+
filename,
|
|
279
|
+
existing_target_validator=existing_target_validator,
|
|
280
|
+
)
|
|
281
|
+
if destination.target_existed and not replacement_confirmed:
|
|
282
|
+
raise UnsafeExportDestinationError(
|
|
283
|
+
"Replacement of the existing export file was not confirmed."
|
|
284
|
+
)
|
|
285
|
+
return replace(
|
|
286
|
+
destination,
|
|
287
|
+
replacement_confirmed=bool(
|
|
288
|
+
replacement_confirmed and destination.target_existed
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def write_export_atomically(
|
|
294
|
+
destination: ExportDestinationTransaction | str | os.PathLike[str],
|
|
295
|
+
writer: Callable[[str], Any],
|
|
296
|
+
*,
|
|
297
|
+
before_publish: Callable[[], Any] | None = None,
|
|
298
|
+
) -> bool:
|
|
299
|
+
"""Stage beside an approved target and publish without widening consent.
|
|
300
|
+
|
|
301
|
+
Raw paths remain accepted only for a target that is absent at approval.
|
|
302
|
+
Replacing an existing target requires an ``ExportDestinationTransaction``
|
|
303
|
+
whose confirmation flag came from the user-facing approval step.
|
|
304
|
+
"""
|
|
305
|
+
if not isinstance(destination, ExportDestinationTransaction):
|
|
306
|
+
destination = prepare_export_destination(None, os.fspath(destination))
|
|
307
|
+
|
|
308
|
+
parent_fd = _open_parent_directory(destination)
|
|
309
|
+
stage_fd: int | None = None
|
|
310
|
+
stage_path = ""
|
|
311
|
+
stage_name = ""
|
|
312
|
+
stage_identity: tuple[int, ...] | None = None
|
|
313
|
+
try:
|
|
314
|
+
# This is deliberately the last operation before staging begins.
|
|
315
|
+
destination.validate(parent_fd=parent_fd)
|
|
316
|
+
suffix = os.path.splitext(destination.filename)[1] or ".tmp"
|
|
317
|
+
stage_fd, stage_path = tempfile.mkstemp(
|
|
318
|
+
prefix=f".{os.path.basename(destination.filename)}.",
|
|
319
|
+
suffix=suffix,
|
|
320
|
+
dir=destination.parent_path,
|
|
321
|
+
)
|
|
322
|
+
stage_name = os.path.basename(stage_path)
|
|
323
|
+
stage_identity = _stage_identity(os.fstat(stage_fd))
|
|
324
|
+
_verify_owned_stage(
|
|
325
|
+
destination,
|
|
326
|
+
stage_path,
|
|
327
|
+
stage_name,
|
|
328
|
+
stage_identity,
|
|
329
|
+
parent_fd=parent_fd,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if writer(stage_path) is False:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
_verify_owned_stage(
|
|
336
|
+
destination,
|
|
337
|
+
stage_path,
|
|
338
|
+
stage_name,
|
|
339
|
+
stage_identity,
|
|
340
|
+
parent_fd=parent_fd,
|
|
341
|
+
)
|
|
342
|
+
os.fsync(stage_fd)
|
|
343
|
+
os.close(stage_fd)
|
|
344
|
+
stage_fd = None
|
|
345
|
+
|
|
346
|
+
if before_publish is not None:
|
|
347
|
+
before_publish()
|
|
348
|
+
|
|
349
|
+
# A callback can replace the private pathname just as it can replace
|
|
350
|
+
# the public target. Revalidate the owned stage after every callback,
|
|
351
|
+
# then leave destination validation immediately adjacent to publish.
|
|
352
|
+
_verify_owned_stage(
|
|
353
|
+
destination,
|
|
354
|
+
stage_path,
|
|
355
|
+
stage_name,
|
|
356
|
+
stage_identity,
|
|
357
|
+
parent_fd=parent_fd,
|
|
358
|
+
)
|
|
359
|
+
if destination.target_existed:
|
|
360
|
+
_replace_approved_target(
|
|
361
|
+
destination,
|
|
362
|
+
stage_path,
|
|
363
|
+
stage_name,
|
|
364
|
+
stage_identity,
|
|
365
|
+
parent_fd=parent_fd,
|
|
366
|
+
)
|
|
367
|
+
stage_path = ""
|
|
368
|
+
else:
|
|
369
|
+
_publish_new_target(
|
|
370
|
+
destination,
|
|
371
|
+
stage_path,
|
|
372
|
+
stage_name,
|
|
373
|
+
stage_identity,
|
|
374
|
+
parent_fd=parent_fd,
|
|
375
|
+
)
|
|
376
|
+
stage_path = ""
|
|
377
|
+
|
|
378
|
+
_sync_parent_directory(parent_fd)
|
|
379
|
+
return True
|
|
380
|
+
finally:
|
|
381
|
+
if stage_fd is not None:
|
|
382
|
+
os.close(stage_fd)
|
|
383
|
+
if stage_path:
|
|
384
|
+
_unlink_owned_stage(
|
|
385
|
+
destination,
|
|
386
|
+
stage_path,
|
|
387
|
+
stage_name,
|
|
388
|
+
stage_identity,
|
|
389
|
+
parent_fd=parent_fd,
|
|
390
|
+
)
|
|
391
|
+
if parent_fd is not None:
|
|
392
|
+
os.close(parent_fd)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def collect_protected_database_files(
|
|
396
|
+
owner: object | None,
|
|
397
|
+
) -> ProtectedDatabaseFiles:
|
|
398
|
+
"""Collect current and retained DB artifacts without opening SQLite."""
|
|
399
|
+
identities: set[DatabaseFileIdentity] = set()
|
|
400
|
+
database_paths: set[str] = set()
|
|
401
|
+
|
|
402
|
+
def add_identity(candidate: object) -> None:
|
|
403
|
+
if isinstance(candidate, tuple) and len(candidate) in {2, 3}:
|
|
404
|
+
try:
|
|
405
|
+
hash(candidate)
|
|
406
|
+
except TypeError:
|
|
407
|
+
return
|
|
408
|
+
identities.add(candidate) # type: ignore[arg-type]
|
|
409
|
+
|
|
410
|
+
def add_identities(candidates: object) -> None:
|
|
411
|
+
add_identity(candidates)
|
|
412
|
+
if isinstance(candidates, (frozenset, set, list)):
|
|
413
|
+
for candidate in candidates:
|
|
414
|
+
add_identity(candidate)
|
|
415
|
+
|
|
416
|
+
def add_path(candidate: object) -> None:
|
|
417
|
+
if candidate:
|
|
418
|
+
try:
|
|
419
|
+
database_paths.add(os.path.abspath(os.fspath(candidate)))
|
|
420
|
+
except (TypeError, ValueError):
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
def add_source(candidate: object) -> None:
|
|
424
|
+
if candidate is None:
|
|
425
|
+
return
|
|
426
|
+
if isinstance(candidate, (str, os.PathLike)):
|
|
427
|
+
add_path(candidate)
|
|
428
|
+
return
|
|
429
|
+
add_identity(candidate)
|
|
430
|
+
for attribute in ("database_identity", "identity"):
|
|
431
|
+
try:
|
|
432
|
+
add_identity(getattr(candidate, attribute, None))
|
|
433
|
+
except RuntimeError:
|
|
434
|
+
pass
|
|
435
|
+
for attribute in (
|
|
436
|
+
"sidecar_identities",
|
|
437
|
+
"sqlite_sidecar_identities",
|
|
438
|
+
"protected_database_identities",
|
|
439
|
+
):
|
|
440
|
+
try:
|
|
441
|
+
add_identities(getattr(candidate, attribute, None))
|
|
442
|
+
except RuntimeError:
|
|
443
|
+
pass
|
|
444
|
+
for attribute in (
|
|
445
|
+
"database_path",
|
|
446
|
+
"logical_path",
|
|
447
|
+
"path_to_db",
|
|
448
|
+
"resolved_database_path",
|
|
449
|
+
"resolved_path",
|
|
450
|
+
):
|
|
451
|
+
try:
|
|
452
|
+
add_path(getattr(candidate, attribute, None))
|
|
453
|
+
except RuntimeError:
|
|
454
|
+
pass
|
|
455
|
+
try:
|
|
456
|
+
cache = getattr(candidate, "cache", None)
|
|
457
|
+
cached_dataset = getattr(cache, "_dataset", None)
|
|
458
|
+
add_path(getattr(cached_dataset, "path_to_db", None))
|
|
459
|
+
except RuntimeError:
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
application = qtw.QApplication.instance()
|
|
463
|
+
owners: list[object] = []
|
|
464
|
+
if owner is not None:
|
|
465
|
+
owners.append(owner)
|
|
466
|
+
if application is not None:
|
|
467
|
+
# Do not retain protection observations from hidden, deleted-later Qt
|
|
468
|
+
# test or closed-window objects. Their filesystem identities may be
|
|
469
|
+
# reused for an unrelated export on filesystems such as ext4. The
|
|
470
|
+
# active export owner is always included above; other active plot/main
|
|
471
|
+
# windows are top-level and visible.
|
|
472
|
+
for widget in qtw.QApplication.topLevelWidgets():
|
|
473
|
+
try:
|
|
474
|
+
if widget.isVisible():
|
|
475
|
+
owners.append(widget)
|
|
476
|
+
except RuntimeError:
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
seen_owners: set[int] = set()
|
|
480
|
+
for current_owner in owners:
|
|
481
|
+
if id(current_owner) in seen_owners:
|
|
482
|
+
continue
|
|
483
|
+
seen_owners.add(id(current_owner))
|
|
484
|
+
_collect_owner_database_sources(current_owner, add_source, add_identity)
|
|
485
|
+
|
|
486
|
+
logical_paths: set[str] = set()
|
|
487
|
+
resolved_paths: set[str] = set()
|
|
488
|
+
for database_path in database_paths:
|
|
489
|
+
logical_base = logical_database_path(database_path)
|
|
490
|
+
resolved_base = canonical_database_path(logical_base)
|
|
491
|
+
for base_path in {logical_base, resolved_base}:
|
|
492
|
+
for suffix in _DATABASE_ARTIFACT_SUFFIXES:
|
|
493
|
+
artifact_path = f"{base_path}{suffix}"
|
|
494
|
+
logical_paths.add(logical_database_path(artifact_path))
|
|
495
|
+
resolved_paths.add(canonical_database_path(artifact_path))
|
|
496
|
+
identity = database_file_identity(artifact_path)
|
|
497
|
+
if identity is not None:
|
|
498
|
+
identities.add(identity)
|
|
499
|
+
|
|
500
|
+
return ProtectedDatabaseFiles(
|
|
501
|
+
logical_paths=frozenset(logical_paths),
|
|
502
|
+
resolved_paths=frozenset(resolved_paths),
|
|
503
|
+
identities=frozenset(identities),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _collect_owner_database_sources(
|
|
508
|
+
owner: object,
|
|
509
|
+
add_source: Callable[[object], None],
|
|
510
|
+
add_identity: Callable[[object], None],
|
|
511
|
+
) -> None:
|
|
512
|
+
"""Inspect the bounded set of qPlot state holders used by its windows."""
|
|
513
|
+
add_source(owner)
|
|
514
|
+
for attribute in (
|
|
515
|
+
"_dataset_key",
|
|
516
|
+
"_selected_dataset_key",
|
|
517
|
+
"_loaded_database_instance",
|
|
518
|
+
"_database_refresh_instance",
|
|
519
|
+
"_database_load_worker",
|
|
520
|
+
"_database_refresh_worker",
|
|
521
|
+
"_database_detail_worker",
|
|
522
|
+
"_database_expensive_detail_worker",
|
|
523
|
+
"_test_database_generation_worker",
|
|
524
|
+
"worker",
|
|
525
|
+
):
|
|
526
|
+
try:
|
|
527
|
+
add_source(getattr(owner, attribute, None))
|
|
528
|
+
except RuntimeError:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
for holder_name in ("_dataset_holder", "dataset_holder"):
|
|
532
|
+
try:
|
|
533
|
+
holder = getattr(owner, holder_name, None)
|
|
534
|
+
items = getattr(holder, "items", None)
|
|
535
|
+
if callable(items):
|
|
536
|
+
for dataset_key, dataset_handle in items():
|
|
537
|
+
add_source(dataset_key)
|
|
538
|
+
add_source(dataset_handle)
|
|
539
|
+
except (RuntimeError, TypeError):
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
load_state = getattr(owner, "_database_load_state", None)
|
|
544
|
+
except RuntimeError:
|
|
545
|
+
load_state = None
|
|
546
|
+
if isinstance(load_state, Mapping):
|
|
547
|
+
add_source(load_state.get("load_instance"))
|
|
548
|
+
add_identity(load_state.get("load_identity"))
|
|
549
|
+
add_source(load_state.get("abspath"))
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
replacement_state = getattr(owner, "_test_database_replacement_state", None)
|
|
553
|
+
except RuntimeError:
|
|
554
|
+
replacement_state = None
|
|
555
|
+
add_source(replacement_state)
|
|
556
|
+
add_source(getattr(replacement_state, "original_instance", None))
|
|
557
|
+
|
|
558
|
+
for container_name in ("_plot_workers", "_workers"):
|
|
559
|
+
try:
|
|
560
|
+
container = getattr(owner, container_name, None)
|
|
561
|
+
if isinstance(container, Mapping):
|
|
562
|
+
sources: Iterable[object] = container.values()
|
|
563
|
+
elif isinstance(container, (set, frozenset, list, tuple)):
|
|
564
|
+
sources = container
|
|
565
|
+
else:
|
|
566
|
+
continue
|
|
567
|
+
for source in tuple(sources):
|
|
568
|
+
add_source(source)
|
|
569
|
+
except RuntimeError:
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
file_textbox = getattr(owner, "fileTextbox", None)
|
|
574
|
+
displayed_path = getattr(file_textbox, "text", None)
|
|
575
|
+
if callable(displayed_path):
|
|
576
|
+
add_source(displayed_path())
|
|
577
|
+
except RuntimeError:
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _capture_export_destination(
|
|
582
|
+
owner: object | None,
|
|
583
|
+
filename: str,
|
|
584
|
+
*,
|
|
585
|
+
existing_target_validator: Callable[[str], Any] | None,
|
|
586
|
+
) -> ExportDestinationTransaction:
|
|
587
|
+
filename = os.path.abspath(filename)
|
|
588
|
+
_ensure_windows_export_path_is_unambiguous(filename)
|
|
589
|
+
parent_path = os.path.dirname(filename)
|
|
590
|
+
try:
|
|
591
|
+
parent_stat = os.stat(parent_path)
|
|
592
|
+
except OSError as err:
|
|
593
|
+
raise UnsafeExportDestinationError(
|
|
594
|
+
"The selected export folder is not available."
|
|
595
|
+
) from err
|
|
596
|
+
if not stat.S_ISDIR(parent_stat.st_mode):
|
|
597
|
+
raise UnsafeExportDestinationError(
|
|
598
|
+
"The selected export parent is not a folder."
|
|
599
|
+
)
|
|
600
|
+
if not _directory_identity_is_stable(parent_stat):
|
|
601
|
+
raise UnsafeExportDestinationError(
|
|
602
|
+
"The selected export folder has no stable filesystem identity."
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
target_signature = _target_signature(filename)
|
|
606
|
+
protected_files = collect_protected_database_files(owner)
|
|
607
|
+
_ensure_target_is_not_protected(filename, protected_files)
|
|
608
|
+
if _target_signature(filename) != target_signature:
|
|
609
|
+
raise UnsafeExportDestinationError(
|
|
610
|
+
"The selected export file changed while it was being inspected."
|
|
611
|
+
)
|
|
612
|
+
destination = ExportDestinationTransaction(
|
|
613
|
+
filename=filename,
|
|
614
|
+
parent_path=parent_path,
|
|
615
|
+
parent_identity=_directory_identity(parent_stat),
|
|
616
|
+
target_signature=target_signature,
|
|
617
|
+
replacement_confirmed=False,
|
|
618
|
+
protected_database_files=protected_files,
|
|
619
|
+
owner=owner,
|
|
620
|
+
existing_target_validator=existing_target_validator,
|
|
621
|
+
)
|
|
622
|
+
_run_existing_target_validator(destination, target_signature)
|
|
623
|
+
return destination
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _ensure_target_is_not_protected(
|
|
627
|
+
filename: str,
|
|
628
|
+
protected_files: ProtectedDatabaseFiles,
|
|
629
|
+
*,
|
|
630
|
+
parent_fd: int | None = None,
|
|
631
|
+
) -> None:
|
|
632
|
+
logical_path = logical_database_path(filename)
|
|
633
|
+
resolved_path = canonical_database_path(filename)
|
|
634
|
+
protected_paths = (
|
|
635
|
+
protected_files.logical_paths | protected_files.resolved_paths
|
|
636
|
+
)
|
|
637
|
+
protected_aliases = {
|
|
638
|
+
_filesystem_alias_key(protected_path)
|
|
639
|
+
for protected_path in protected_paths
|
|
640
|
+
}
|
|
641
|
+
if (
|
|
642
|
+
logical_path in protected_paths
|
|
643
|
+
or resolved_path in protected_paths
|
|
644
|
+
or _filesystem_alias_key(logical_path) in protected_aliases
|
|
645
|
+
or _filesystem_alias_key(resolved_path) in protected_aliases
|
|
646
|
+
):
|
|
647
|
+
raise UnsafeExportDestinationError(
|
|
648
|
+
"The selected export path refers to an input database or one of "
|
|
649
|
+
"its SQLite sidecars."
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
target_identity = database_file_identity(filename)
|
|
653
|
+
if (
|
|
654
|
+
target_identity is not None
|
|
655
|
+
and target_identity in protected_files.identities
|
|
656
|
+
):
|
|
657
|
+
raise UnsafeExportDestinationError(
|
|
658
|
+
"The selected export path refers to an input database file."
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# A destination need never have been loaded in qPlot. Conservatively
|
|
662
|
+
# protect every recognizable SQLite file, without opening SQLite or
|
|
663
|
+
# trying to distinguish QCoDeS schemas (which could live only in a WAL).
|
|
664
|
+
header = _read_export_candidate_header(filename, parent_fd=parent_fd)
|
|
665
|
+
if header.startswith(_SQLITE_ARTIFACT_HEADERS):
|
|
666
|
+
raise UnsafeExportDestinationError(
|
|
667
|
+
"The selected export path refers to a SQLite database or journal; "
|
|
668
|
+
"export replacement cannot overwrite database files."
|
|
669
|
+
)
|
|
670
|
+
for suffix in SQLITE_SIDECAR_SUFFIXES:
|
|
671
|
+
if filename.casefold().endswith(suffix):
|
|
672
|
+
database_path = filename[:-len(suffix)]
|
|
673
|
+
if _read_export_candidate_header(
|
|
674
|
+
database_path, parent_fd=parent_fd,
|
|
675
|
+
).startswith(_SQLITE_HEADER):
|
|
676
|
+
raise UnsafeExportDestinationError(
|
|
677
|
+
"The selected export path is reserved for a SQLite "
|
|
678
|
+
"database sidecar."
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _read_export_candidate_header(
|
|
683
|
+
filename: str,
|
|
684
|
+
*,
|
|
685
|
+
parent_fd: int | None,
|
|
686
|
+
) -> bytes:
|
|
687
|
+
"""Read at most 16 bytes from a stable regular file, physically read-only.
|
|
688
|
+
|
|
689
|
+
No SQLite connection, locks, schema traversal, recovery, or sidecar
|
|
690
|
+
creation is involved. Unreadable or changing candidates fail closed.
|
|
691
|
+
NONBLOCK and the regular-file checks avoid waiting on a substituted FIFO.
|
|
692
|
+
The same bounded inspection also handles a sidecar's associated main file.
|
|
693
|
+
"""
|
|
694
|
+
signature = _target_signature(filename, parent_fd=parent_fd)
|
|
695
|
+
if signature is None:
|
|
696
|
+
return b""
|
|
697
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
698
|
+
flags |= getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
699
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
700
|
+
try:
|
|
701
|
+
fd = os.open(
|
|
702
|
+
filename if parent_fd is None else os.path.basename(filename),
|
|
703
|
+
flags,
|
|
704
|
+
**({} if parent_fd is None else {"dir_fd": parent_fd}),
|
|
705
|
+
)
|
|
706
|
+
try:
|
|
707
|
+
opened = os.fstat(fd)
|
|
708
|
+
stat_fields = (
|
|
709
|
+
"st_dev", "st_ino", "st_mode", "st_nlink", "st_size",
|
|
710
|
+
"st_mtime_ns", "st_ctime_ns",
|
|
711
|
+
)
|
|
712
|
+
opened_signature = tuple(int(getattr(opened, field)) for field in stat_fields)
|
|
713
|
+
# On Windows, lstat may report CreationTime as ctime while
|
|
714
|
+
# fstat reports ChangeTime. Compare their common fields, then
|
|
715
|
+
# retain the full timestamps in each API's before/after checks.
|
|
716
|
+
common_fields = 6 if os.name == "nt" else 7
|
|
717
|
+
if opened_signature[:common_fields] != signature[:common_fields]:
|
|
718
|
+
raise UnsafeExportDestinationError(
|
|
719
|
+
"The selected export file changed while it was being inspected."
|
|
720
|
+
)
|
|
721
|
+
header = os.read(fd, len(_SQLITE_HEADER))
|
|
722
|
+
after_read = os.fstat(fd)
|
|
723
|
+
if tuple(int(getattr(after_read, field)) for field in stat_fields) != opened_signature:
|
|
724
|
+
raise UnsafeExportDestinationError(
|
|
725
|
+
"The selected export file changed while it was being inspected."
|
|
726
|
+
)
|
|
727
|
+
finally:
|
|
728
|
+
os.close(fd)
|
|
729
|
+
except OSError as err:
|
|
730
|
+
raise UnsafeExportDestinationError(
|
|
731
|
+
"The selected export file could not be inspected safely."
|
|
732
|
+
) from err
|
|
733
|
+
if _target_signature(filename, parent_fd=parent_fd) != signature:
|
|
734
|
+
raise UnsafeExportDestinationError(
|
|
735
|
+
"The selected export file changed while it was being inspected."
|
|
736
|
+
)
|
|
737
|
+
return header
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _filesystem_alias_key(filename: str) -> str:
|
|
741
|
+
"""Return a conservative key for case/Unicode-equivalent path aliases.
|
|
742
|
+
|
|
743
|
+
``normcase`` alone does not model case-insensitive macOS volumes, and
|
|
744
|
+
APFS/HFS resolve canonically equivalent NFC/NFD names to the same entry.
|
|
745
|
+
Treating those spellings as aliases on every platform may reject a rare
|
|
746
|
+
legitimate export on a case-sensitive filesystem, but it cannot let an
|
|
747
|
+
export create an input database's currently absent SQLite sidecar.
|
|
748
|
+
"""
|
|
749
|
+
return unicodedata.normalize("NFC", filename).casefold()
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _directory_identity(file_stat: os.stat_result) -> tuple[int, ...]:
|
|
753
|
+
"""Return fields stable while entries inside a directory are modified."""
|
|
754
|
+
birthtime_ns = int(getattr(file_stat, "st_birthtime_ns", 0) or 0)
|
|
755
|
+
if not birthtime_ns:
|
|
756
|
+
birthtime = getattr(file_stat, "st_birthtime", None)
|
|
757
|
+
if birthtime is not None:
|
|
758
|
+
birthtime_ns = round(float(birthtime) * 1_000_000_000)
|
|
759
|
+
return (
|
|
760
|
+
int(file_stat.st_dev),
|
|
761
|
+
int(file_stat.st_ino),
|
|
762
|
+
int(file_stat.st_mode),
|
|
763
|
+
birthtime_ns,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _directory_identity_is_stable(file_stat: os.stat_result) -> bool:
|
|
768
|
+
"""Return whether a directory observation can detect replacement."""
|
|
769
|
+
return bool(
|
|
770
|
+
int(getattr(file_stat, "st_ino", 0) or 0)
|
|
771
|
+
or int(getattr(file_stat, "st_birthtime_ns", 0) or 0)
|
|
772
|
+
or getattr(file_stat, "st_birthtime", None)
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _target_signature(
|
|
777
|
+
filename: str,
|
|
778
|
+
*,
|
|
779
|
+
parent_fd: int | None = None,
|
|
780
|
+
) -> tuple[object, ...] | None:
|
|
781
|
+
"""Capture an existing ordinary target without following its final link."""
|
|
782
|
+
try:
|
|
783
|
+
if parent_fd is None:
|
|
784
|
+
file_stat = os.lstat(filename)
|
|
785
|
+
else:
|
|
786
|
+
file_stat = os.stat(
|
|
787
|
+
os.path.basename(filename),
|
|
788
|
+
dir_fd=parent_fd,
|
|
789
|
+
follow_symlinks=False,
|
|
790
|
+
)
|
|
791
|
+
except FileNotFoundError:
|
|
792
|
+
return None
|
|
793
|
+
except OSError as err:
|
|
794
|
+
raise UnsafeExportDestinationError(
|
|
795
|
+
"The selected export file could not be inspected safely."
|
|
796
|
+
) from err
|
|
797
|
+
|
|
798
|
+
if stat.S_ISLNK(file_stat.st_mode):
|
|
799
|
+
raise UnsafeExportDestinationError(
|
|
800
|
+
"Symbolic-link export destinations are not supported."
|
|
801
|
+
)
|
|
802
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
803
|
+
raise UnsafeExportDestinationError(
|
|
804
|
+
"The selected export destination is not a regular file."
|
|
805
|
+
)
|
|
806
|
+
if int(file_stat.st_nlink) != 1:
|
|
807
|
+
raise UnsafeExportDestinationError(
|
|
808
|
+
"Hard-linked export destinations are not supported."
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
inode = int(getattr(file_stat, "st_ino", 0) or 0)
|
|
812
|
+
stable_identity: DatabaseFileIdentity | None
|
|
813
|
+
if inode:
|
|
814
|
+
stable_identity = (int(file_stat.st_dev), inode)
|
|
815
|
+
else:
|
|
816
|
+
stable_identity = database_file_identity(filename)
|
|
817
|
+
if stable_identity is None:
|
|
818
|
+
raise UnsafeExportDestinationError(
|
|
819
|
+
"The selected export file has no stable filesystem identity."
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
return (
|
|
823
|
+
int(file_stat.st_dev),
|
|
824
|
+
inode,
|
|
825
|
+
int(file_stat.st_mode),
|
|
826
|
+
int(file_stat.st_nlink),
|
|
827
|
+
int(file_stat.st_size),
|
|
828
|
+
int(file_stat.st_mtime_ns),
|
|
829
|
+
int(file_stat.st_ctime_ns),
|
|
830
|
+
stable_identity,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _run_existing_target_validator(
|
|
835
|
+
destination: ExportDestinationTransaction,
|
|
836
|
+
current_signature: tuple[object, ...] | None,
|
|
837
|
+
) -> None:
|
|
838
|
+
validator = destination.existing_target_validator
|
|
839
|
+
if current_signature is None or validator is None:
|
|
840
|
+
return
|
|
841
|
+
try:
|
|
842
|
+
result = validator(destination.filename)
|
|
843
|
+
except UnsafeExportDestinationError:
|
|
844
|
+
raise
|
|
845
|
+
except Exception as err:
|
|
846
|
+
raise UnsafeExportDestinationError(
|
|
847
|
+
"The existing export file could not be validated safely."
|
|
848
|
+
) from err
|
|
849
|
+
if result is False:
|
|
850
|
+
raise UnsafeExportDestinationError(
|
|
851
|
+
"The existing export file did not match the selected format."
|
|
852
|
+
)
|
|
853
|
+
if _target_signature(destination.filename) != current_signature:
|
|
854
|
+
raise UnsafeExportDestinationError(
|
|
855
|
+
"The selected export file changed while it was being inspected."
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _ensure_parent_is_unchanged(
|
|
860
|
+
destination: ExportDestinationTransaction,
|
|
861
|
+
*,
|
|
862
|
+
parent_fd: int | None,
|
|
863
|
+
) -> None:
|
|
864
|
+
try:
|
|
865
|
+
current_parent = os.stat(destination.parent_path)
|
|
866
|
+
except OSError as err:
|
|
867
|
+
raise UnsafeExportDestinationError(
|
|
868
|
+
"The selected folder changed while printing or exporting."
|
|
869
|
+
) from err
|
|
870
|
+
if (
|
|
871
|
+
not stat.S_ISDIR(current_parent.st_mode)
|
|
872
|
+
or not _directory_identity_is_stable(current_parent)
|
|
873
|
+
or _directory_identity(current_parent) != destination.parent_identity
|
|
874
|
+
):
|
|
875
|
+
raise UnsafeExportDestinationError(
|
|
876
|
+
"The selected folder changed while printing or exporting."
|
|
877
|
+
)
|
|
878
|
+
if parent_fd is not None:
|
|
879
|
+
try:
|
|
880
|
+
opened_parent = os.fstat(parent_fd)
|
|
881
|
+
except OSError as err:
|
|
882
|
+
raise UnsafeExportDestinationError(
|
|
883
|
+
"The selected export folder is no longer available."
|
|
884
|
+
) from err
|
|
885
|
+
if _directory_identity(opened_parent) != destination.parent_identity:
|
|
886
|
+
raise UnsafeExportDestinationError(
|
|
887
|
+
"The selected folder changed while printing or exporting."
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _ensure_windows_export_path_is_unambiguous(filename: str) -> None:
|
|
892
|
+
"""Reject Win32 spellings that alias or attach streams to another file."""
|
|
893
|
+
if os.name != "nt":
|
|
894
|
+
return
|
|
895
|
+
|
|
896
|
+
_drive, tail = ntpath.splitdrive(filename)
|
|
897
|
+
components = tuple(
|
|
898
|
+
component
|
|
899
|
+
for component in re.split(r"[\\/]", tail)
|
|
900
|
+
if component
|
|
901
|
+
)
|
|
902
|
+
reserved_names = {"con", "prn", "aux", "nul", "conin$", "conout$"}
|
|
903
|
+
reserved_names.update(f"com{number}" for number in range(1, 10))
|
|
904
|
+
reserved_names.update(f"lpt{number}" for number in range(1, 10))
|
|
905
|
+
|
|
906
|
+
for component in components:
|
|
907
|
+
if component.endswith((" ", ".")) or ":" in component:
|
|
908
|
+
raise UnsafeExportDestinationError(
|
|
909
|
+
"Ambiguous Windows export paths and alternate data streams "
|
|
910
|
+
"are not supported."
|
|
911
|
+
)
|
|
912
|
+
device_name = component.split(".", 1)[0].casefold()
|
|
913
|
+
if device_name in reserved_names:
|
|
914
|
+
raise UnsafeExportDestinationError(
|
|
915
|
+
"Windows device-name export destinations are not supported."
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _open_parent_directory(
|
|
920
|
+
destination: ExportDestinationTransaction,
|
|
921
|
+
) -> int | None:
|
|
922
|
+
flags = os.O_RDONLY
|
|
923
|
+
flags |= getattr(os, "O_DIRECTORY", 0)
|
|
924
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
925
|
+
try:
|
|
926
|
+
parent_fd = os.open(destination.parent_path, flags)
|
|
927
|
+
except OSError as err:
|
|
928
|
+
if os.name == "nt":
|
|
929
|
+
return None
|
|
930
|
+
raise UnsafeExportDestinationError(
|
|
931
|
+
"The selected export folder could not be anchored safely."
|
|
932
|
+
) from err
|
|
933
|
+
try:
|
|
934
|
+
_ensure_parent_is_unchanged(destination, parent_fd=parent_fd)
|
|
935
|
+
except Exception:
|
|
936
|
+
os.close(parent_fd)
|
|
937
|
+
raise
|
|
938
|
+
return parent_fd
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _stage_identity(file_stat: os.stat_result) -> tuple[int, ...] | None:
|
|
942
|
+
inode = int(getattr(file_stat, "st_ino", 0) or 0)
|
|
943
|
+
birthtime_ns = int(getattr(file_stat, "st_birthtime_ns", 0) or 0)
|
|
944
|
+
if not birthtime_ns:
|
|
945
|
+
birthtime = getattr(file_stat, "st_birthtime", None)
|
|
946
|
+
if birthtime is not None:
|
|
947
|
+
birthtime_ns = round(float(birthtime) * 1_000_000_000)
|
|
948
|
+
if not inode and not birthtime_ns:
|
|
949
|
+
return None
|
|
950
|
+
return (
|
|
951
|
+
int(file_stat.st_dev),
|
|
952
|
+
inode,
|
|
953
|
+
stat.S_IFMT(int(file_stat.st_mode)),
|
|
954
|
+
birthtime_ns,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _stage_path_stat(
|
|
959
|
+
stage_path: str,
|
|
960
|
+
stage_name: str,
|
|
961
|
+
*,
|
|
962
|
+
parent_fd: int | None,
|
|
963
|
+
) -> os.stat_result | None:
|
|
964
|
+
try:
|
|
965
|
+
if parent_fd is None:
|
|
966
|
+
return os.lstat(stage_path)
|
|
967
|
+
return os.stat(
|
|
968
|
+
stage_name,
|
|
969
|
+
dir_fd=parent_fd,
|
|
970
|
+
follow_symlinks=False,
|
|
971
|
+
)
|
|
972
|
+
except OSError:
|
|
973
|
+
return None
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _stage_database_identity(
|
|
977
|
+
stage_path: str,
|
|
978
|
+
stage_stat: os.stat_result,
|
|
979
|
+
) -> DatabaseFileIdentity | None:
|
|
980
|
+
inode = int(getattr(stage_stat, "st_ino", 0) or 0)
|
|
981
|
+
if inode:
|
|
982
|
+
return (int(stage_stat.st_dev), inode)
|
|
983
|
+
return database_file_identity(stage_path)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _stage_refers_to_protected_database(
|
|
987
|
+
destination: ExportDestinationTransaction,
|
|
988
|
+
stage_path: str,
|
|
989
|
+
stage_stat: os.stat_result,
|
|
990
|
+
) -> bool:
|
|
991
|
+
protected_files = destination.protected_database_files.merged(
|
|
992
|
+
collect_protected_database_files(destination.owner)
|
|
993
|
+
)
|
|
994
|
+
stage_database_identity = _stage_database_identity(stage_path, stage_stat)
|
|
995
|
+
return (
|
|
996
|
+
stage_database_identity is None
|
|
997
|
+
or stage_database_identity in protected_files.identities
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _verify_owned_stage(
|
|
1002
|
+
destination: ExportDestinationTransaction,
|
|
1003
|
+
stage_path: str,
|
|
1004
|
+
stage_name: str,
|
|
1005
|
+
stage_identity: tuple[int, ...] | None,
|
|
1006
|
+
*,
|
|
1007
|
+
parent_fd: int | None,
|
|
1008
|
+
) -> None:
|
|
1009
|
+
if stage_identity is None:
|
|
1010
|
+
raise UnsafeExportDestinationError(
|
|
1011
|
+
"The private export staging file has no stable identity."
|
|
1012
|
+
)
|
|
1013
|
+
_ensure_parent_is_unchanged(destination, parent_fd=parent_fd)
|
|
1014
|
+
stage_stat = _stage_path_stat(
|
|
1015
|
+
stage_path,
|
|
1016
|
+
stage_name,
|
|
1017
|
+
parent_fd=parent_fd,
|
|
1018
|
+
)
|
|
1019
|
+
if stage_stat is None or _stage_identity(stage_stat) != stage_identity:
|
|
1020
|
+
raise UnsafeExportDestinationError(
|
|
1021
|
+
"The private export staging file changed unexpectedly."
|
|
1022
|
+
)
|
|
1023
|
+
if not stat.S_ISREG(stage_stat.st_mode) or int(stage_stat.st_nlink) != 1:
|
|
1024
|
+
raise UnsafeExportDestinationError(
|
|
1025
|
+
"The private export staging target is not an unlinked regular file."
|
|
1026
|
+
)
|
|
1027
|
+
if _stage_refers_to_protected_database(
|
|
1028
|
+
destination,
|
|
1029
|
+
stage_path,
|
|
1030
|
+
stage_stat,
|
|
1031
|
+
):
|
|
1032
|
+
raise UnsafeExportDestinationError(
|
|
1033
|
+
"The private export staging file refers to an input database."
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _replace_approved_target(
|
|
1038
|
+
destination: ExportDestinationTransaction,
|
|
1039
|
+
stage_path: str,
|
|
1040
|
+
stage_name: str,
|
|
1041
|
+
stage_identity: tuple[int, ...] | None,
|
|
1042
|
+
*,
|
|
1043
|
+
parent_fd: int | None,
|
|
1044
|
+
) -> None:
|
|
1045
|
+
# Keep the target/protection check in the publication helper so no qPlot
|
|
1046
|
+
# callback or format-specific code can sit between it and the rename.
|
|
1047
|
+
destination.validate(parent_fd=parent_fd)
|
|
1048
|
+
_verify_owned_stage(
|
|
1049
|
+
destination,
|
|
1050
|
+
stage_path,
|
|
1051
|
+
stage_name,
|
|
1052
|
+
stage_identity,
|
|
1053
|
+
parent_fd=parent_fd,
|
|
1054
|
+
)
|
|
1055
|
+
if not destination.replacement_confirmed:
|
|
1056
|
+
raise UnsafeExportDestinationError(
|
|
1057
|
+
"Replacement of the existing export file was not confirmed."
|
|
1058
|
+
)
|
|
1059
|
+
if parent_fd is None:
|
|
1060
|
+
os.replace(stage_path, destination.filename)
|
|
1061
|
+
else:
|
|
1062
|
+
os.replace(
|
|
1063
|
+
stage_name,
|
|
1064
|
+
os.path.basename(destination.filename),
|
|
1065
|
+
src_dir_fd=parent_fd,
|
|
1066
|
+
dst_dir_fd=parent_fd,
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _publish_new_target(
|
|
1071
|
+
destination: ExportDestinationTransaction,
|
|
1072
|
+
stage_path: str,
|
|
1073
|
+
stage_name: str,
|
|
1074
|
+
stage_identity: tuple[int, ...] | None,
|
|
1075
|
+
*,
|
|
1076
|
+
parent_fd: int | None,
|
|
1077
|
+
) -> None:
|
|
1078
|
+
"""Publish with atomic no-clobber semantics for an absent approval."""
|
|
1079
|
+
destination.validate(parent_fd=parent_fd)
|
|
1080
|
+
_verify_owned_stage(
|
|
1081
|
+
destination,
|
|
1082
|
+
stage_path,
|
|
1083
|
+
stage_name,
|
|
1084
|
+
stage_identity,
|
|
1085
|
+
parent_fd=parent_fd,
|
|
1086
|
+
)
|
|
1087
|
+
try:
|
|
1088
|
+
if os.name == "nt":
|
|
1089
|
+
# Win32 rename is atomic and refuses an existing destination. It
|
|
1090
|
+
# also supports FAT/exFAT volumes where hard links are unavailable.
|
|
1091
|
+
os.rename(stage_path, destination.filename)
|
|
1092
|
+
elif parent_fd is None:
|
|
1093
|
+
os.link(stage_path, destination.filename, follow_symlinks=False)
|
|
1094
|
+
else:
|
|
1095
|
+
os.link(
|
|
1096
|
+
stage_name,
|
|
1097
|
+
os.path.basename(destination.filename),
|
|
1098
|
+
src_dir_fd=parent_fd,
|
|
1099
|
+
dst_dir_fd=parent_fd,
|
|
1100
|
+
follow_symlinks=False,
|
|
1101
|
+
)
|
|
1102
|
+
except FileExistsError as err:
|
|
1103
|
+
raise UnsafeExportDestinationError(
|
|
1104
|
+
"The selected export file appeared before publication; it was "
|
|
1105
|
+
"not replaced."
|
|
1106
|
+
) from err
|
|
1107
|
+
except OSError as err:
|
|
1108
|
+
raise UnsafeExportDestinationError(
|
|
1109
|
+
"The new export could not be published with no-clobber semantics."
|
|
1110
|
+
) from err
|
|
1111
|
+
|
|
1112
|
+
_unlink_owned_stage(
|
|
1113
|
+
destination,
|
|
1114
|
+
stage_path,
|
|
1115
|
+
stage_name,
|
|
1116
|
+
stage_identity,
|
|
1117
|
+
parent_fd=parent_fd,
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _unlink_owned_stage(
|
|
1122
|
+
destination: ExportDestinationTransaction,
|
|
1123
|
+
stage_path: str,
|
|
1124
|
+
stage_name: str,
|
|
1125
|
+
stage_identity: tuple[int, ...] | None,
|
|
1126
|
+
*,
|
|
1127
|
+
parent_fd: int | None,
|
|
1128
|
+
) -> None:
|
|
1129
|
+
"""Unlink only the exact private entry created by this transaction."""
|
|
1130
|
+
if stage_identity is None:
|
|
1131
|
+
return
|
|
1132
|
+
current_stat = _stage_path_stat(
|
|
1133
|
+
stage_path,
|
|
1134
|
+
stage_name,
|
|
1135
|
+
parent_fd=parent_fd,
|
|
1136
|
+
)
|
|
1137
|
+
if current_stat is None or _stage_identity(current_stat) != stage_identity:
|
|
1138
|
+
return
|
|
1139
|
+
if _stage_refers_to_protected_database(
|
|
1140
|
+
destination,
|
|
1141
|
+
stage_path,
|
|
1142
|
+
current_stat,
|
|
1143
|
+
):
|
|
1144
|
+
return
|
|
1145
|
+
try:
|
|
1146
|
+
if parent_fd is None:
|
|
1147
|
+
current_parent = os.stat(destination.parent_path)
|
|
1148
|
+
if _directory_identity(current_parent) != destination.parent_identity:
|
|
1149
|
+
return
|
|
1150
|
+
os.unlink(stage_path)
|
|
1151
|
+
else:
|
|
1152
|
+
os.unlink(stage_name, dir_fd=parent_fd)
|
|
1153
|
+
except FileNotFoundError:
|
|
1154
|
+
pass
|
|
1155
|
+
except OSError:
|
|
1156
|
+
# Leaving an untrusted or unverifiable private entry is safer than
|
|
1157
|
+
# unlinking a pathname that may now refer to another file instance.
|
|
1158
|
+
pass
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _sync_parent_directory(parent_fd: int | None) -> None:
|
|
1162
|
+
if parent_fd is None:
|
|
1163
|
+
return
|
|
1164
|
+
try:
|
|
1165
|
+
os.fsync(parent_fd)
|
|
1166
|
+
except OSError:
|
|
1167
|
+
pass
|