l3io-wordpress-database 0.3.5__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.
- l3io/wp/database/__init__.py +192 -0
- l3io/wp/database/__main__.py +10 -0
- l3io/wp/database/_client.py +318 -0
- l3io/wp/database/_observe.py +157 -0
- l3io/wp/database/_passphrase.py +237 -0
- l3io/wp/database/_sql.py +79 -0
- l3io/wp/database/_version.py +24 -0
- l3io/wp/database/_version_map.py +35 -0
- l3io/wp/database/auth.py +64 -0
- l3io/wp/database/cli.py +531 -0
- l3io/wp/database/connection.py +71 -0
- l3io/wp/database/credentials.py +196 -0
- l3io/wp/database/encryption.py +188 -0
- l3io/wp/database/errors.py +186 -0
- l3io/wp/database/facade.py +220 -0
- l3io/wp/database/identifiers.py +95 -0
- l3io/wp/database/inspection.py +254 -0
- l3io/wp/database/provision.py +751 -0
- l3io/wp/database/py.typed +0 -0
- l3io/wp/database/server.py +139 -0
- l3io/wp/database/state.py +82 -0
- l3io/wp/database/tls.py +211 -0
- l3io/wp/database/transfer.py +1165 -0
- l3io/wp/database/verdict.py +193 -0
- l3io/wp/database/versions.py +101 -0
- l3io/wp/database/wpconfig.py +145 -0
- l3io_wordpress_database-0.3.5.dist-info/METADATA +239 -0
- l3io_wordpress_database-0.3.5.dist-info/RECORD +31 -0
- l3io_wordpress_database-0.3.5.dist-info/WHEEL +4 -0
- l3io_wordpress_database-0.3.5.dist-info/entry_points.txt +2 -0
- l3io_wordpress_database-0.3.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
"""Export and import, through the server's own client programs (spec §7.3).
|
|
2
|
+
|
|
3
|
+
One engine per direction. :class:`DumpStream` is the dump engine: an iterator of
|
|
4
|
+
chunks that raises :class:`TransferError` at the end, instead of stopping, when
|
|
5
|
+
the program failed, so a truncated dump is never mistaken for a whole one.
|
|
6
|
+
``dump`` writes it to a stream or, atomically, to a path. ``load`` (below) peeks
|
|
7
|
+
the source's first bytes to detect age encryption, the dump's family and
|
|
8
|
+
MariaDB's sandbox line; it never parses SQL (global rule 1).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import itertools
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
import weakref
|
|
21
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
22
|
+
from contextlib import ExitStack, suppress
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import IO, Any
|
|
26
|
+
|
|
27
|
+
import pymysql
|
|
28
|
+
|
|
29
|
+
from . import _client
|
|
30
|
+
from ._client import ClientTool
|
|
31
|
+
from ._observe import VERDICT_KEY, Running, operation
|
|
32
|
+
from ._sql import open_connection
|
|
33
|
+
from .connection import Connection
|
|
34
|
+
from .credentials import CredentialSource, ResolvedCredentials
|
|
35
|
+
from .errors import (
|
|
36
|
+
DatabaseError,
|
|
37
|
+
DatabaseUnreachableError,
|
|
38
|
+
InvalidArgumentsError,
|
|
39
|
+
RefusedError,
|
|
40
|
+
TransferError,
|
|
41
|
+
)
|
|
42
|
+
from .identifiers import grant_pattern, quote_identifier
|
|
43
|
+
from .provision import _as_package_error, _confirmation, _preflight_resolved, _resolve_or_finding
|
|
44
|
+
from .server import ServerFamily, ServerInfo, detect_server
|
|
45
|
+
from .verdict import Code, Finding, Verdict
|
|
46
|
+
|
|
47
|
+
CHUNK = 64 * 1024
|
|
48
|
+
STDERR_CAP = 64 * 1024
|
|
49
|
+
|
|
50
|
+
#: Where the option-file pipe is named for the child; absent on Windows.
|
|
51
|
+
DEV_FD = Path("/dev/fd")
|
|
52
|
+
|
|
53
|
+
DUMP_PRIVILEGES = ("SELECT", "SHOW VIEW", "TRIGGER")
|
|
54
|
+
LOAD_PRIVILEGES = ("CREATE", "INSERT", "ALTER", "DROP", "INDEX", "REFERENCES", "CREATE VIEW")
|
|
55
|
+
DROP_TABLES_PRIVILEGES = ("DROP",)
|
|
56
|
+
|
|
57
|
+
#: Same character set as :func:`identifiers.validate_database_name`, applied to a
|
|
58
|
+
#: table-name prefix that will be LIKE-matched and then used only to select
|
|
59
|
+
#: already-quoted identifiers for ``DROP``.
|
|
60
|
+
_PREFIX_FORBIDDEN = frozenset({"\x00", "\n", "\r", "`", "/", "\\", "."})
|
|
61
|
+
MAX_TABLE_PREFIX = 64
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class TransferResult:
|
|
66
|
+
"""What a dump or load moved, and what its check found."""
|
|
67
|
+
|
|
68
|
+
bytes: int
|
|
69
|
+
duration_ms: float
|
|
70
|
+
tool: str
|
|
71
|
+
server: ServerInfo
|
|
72
|
+
verdict: Verdict
|
|
73
|
+
dropped: tuple[str, ...] = ()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class DropTablesResult:
|
|
78
|
+
"""Tables matching a prefix, and what the check found."""
|
|
79
|
+
|
|
80
|
+
tables: tuple[str, ...]
|
|
81
|
+
verdict: Verdict
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _Stderr:
|
|
85
|
+
"""Drains a program's stderr so it can never block, keeping the first STDERR_CAP bytes."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, pipe: IO[bytes]) -> None:
|
|
88
|
+
self._pipe = pipe
|
|
89
|
+
self._kept = bytearray()
|
|
90
|
+
self._thread = threading.Thread(target=self._drain, daemon=True)
|
|
91
|
+
self._thread.start()
|
|
92
|
+
|
|
93
|
+
def _drain(self) -> None:
|
|
94
|
+
with self._pipe:
|
|
95
|
+
for block in iter(lambda: self._pipe.read(CHUNK), b""):
|
|
96
|
+
room = STDERR_CAP - len(self._kept)
|
|
97
|
+
if room > 0:
|
|
98
|
+
self._kept += block[:room]
|
|
99
|
+
|
|
100
|
+
def text(self, password: str) -> str:
|
|
101
|
+
self._thread.join(timeout=10)
|
|
102
|
+
text = self._kept.decode("utf-8", errors="replace")
|
|
103
|
+
return text.replace(password, "***") if password else text
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _start(
|
|
107
|
+
tool: ClientTool,
|
|
108
|
+
args: Sequence[str],
|
|
109
|
+
credentials: ResolvedCredentials,
|
|
110
|
+
connection: Connection,
|
|
111
|
+
*,
|
|
112
|
+
for_load: bool,
|
|
113
|
+
) -> subprocess.Popen[bytes]:
|
|
114
|
+
"""Run a client program with its options in a pipe and a minimal environment."""
|
|
115
|
+
with _client.option_pipe(credentials, connection, tool.family) as fd:
|
|
116
|
+
return subprocess.Popen( # noqa: S603 - argv built from a found tool and an allow-list
|
|
117
|
+
_client.argv(tool, fd, args),
|
|
118
|
+
stdin=subprocess.PIPE if for_load else subprocess.DEVNULL,
|
|
119
|
+
stdout=subprocess.DEVNULL if for_load else subprocess.PIPE,
|
|
120
|
+
stderr=subprocess.PIPE,
|
|
121
|
+
env=_client.child_env(),
|
|
122
|
+
pass_fds=(fd,),
|
|
123
|
+
close_fds=True,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _DumpRun:
|
|
128
|
+
"""A running dump's parts, held apart from its :class:`DumpStream`.
|
|
129
|
+
|
|
130
|
+
The stream's finalizer and the encryption thread reference this, never the
|
|
131
|
+
stream, so a stream dropped unfinished can still be collected and stopped.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, process: subprocess.Popen[bytes], verdict: Verdict, on_finish: Any) -> None:
|
|
135
|
+
if process.stdout is None or process.stderr is None:
|
|
136
|
+
msg = "a dump process needs piped stdout and stderr"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
self.process = process
|
|
139
|
+
self.verdict = verdict
|
|
140
|
+
self.stderr = _Stderr(process.stderr)
|
|
141
|
+
self.reader: IO[bytes] = process.stdout
|
|
142
|
+
self.thread: threading.Thread | None = None
|
|
143
|
+
self.error: BaseException | None = None
|
|
144
|
+
self.bytes = 0
|
|
145
|
+
self._on_finish = on_finish
|
|
146
|
+
self._lock = threading.Lock()
|
|
147
|
+
|
|
148
|
+
def notify(self, error: BaseException | None) -> None:
|
|
149
|
+
"""Tell ``on_finish`` how the dump ended; only the first call counts."""
|
|
150
|
+
with self._lock:
|
|
151
|
+
callback, self._on_finish = self._on_finish, None
|
|
152
|
+
if callback is not None:
|
|
153
|
+
callback(error, self.bytes)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _stop(run: _DumpRun) -> None:
|
|
157
|
+
"""Kill and reap a dump's program, release its pipes and end its operation."""
|
|
158
|
+
try:
|
|
159
|
+
run.process.kill()
|
|
160
|
+
run.process.wait()
|
|
161
|
+
with suppress(OSError):
|
|
162
|
+
run.reader.close()
|
|
163
|
+
# A collection can run this on the encryption thread itself, which cannot join itself.
|
|
164
|
+
if run.thread is not None and run.thread is not threading.current_thread():
|
|
165
|
+
# Killed, the program closes the plaintext pipe; with the reader closed,
|
|
166
|
+
# a thread blocked writing ciphertext fails with EPIPE.
|
|
167
|
+
run.thread.join(timeout=10)
|
|
168
|
+
finally:
|
|
169
|
+
run.notify(TransferError("dump closed before it finished", run.verdict, None, ""))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _start_encryption(
|
|
173
|
+
run: _DumpRun,
|
|
174
|
+
*,
|
|
175
|
+
recipients: Sequence[str] | None = None,
|
|
176
|
+
passphrase: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Encrypt the program's output on a thread; ``run.reader`` becomes the ciphertext."""
|
|
179
|
+
from . import encryption
|
|
180
|
+
|
|
181
|
+
plaintext = run.reader
|
|
182
|
+
read_fd, write_fd = os.pipe()
|
|
183
|
+
run.reader = os.fdopen(read_fd, "rb")
|
|
184
|
+
writer = os.fdopen(write_fd, "wb")
|
|
185
|
+
|
|
186
|
+
def encrypt() -> None:
|
|
187
|
+
try:
|
|
188
|
+
if passphrase is not None:
|
|
189
|
+
encryption.encrypt(plaintext, writer, passphrase=passphrase)
|
|
190
|
+
else:
|
|
191
|
+
encryption.encrypt(plaintext, writer, recipients or ())
|
|
192
|
+
except BaseException as exc: # noqa: BLE001 - re-raised on the consumer's thread
|
|
193
|
+
run.error = exc
|
|
194
|
+
finally:
|
|
195
|
+
with suppress(OSError): # flushing to a reader that was closed
|
|
196
|
+
writer.close()
|
|
197
|
+
# Unread output would otherwise block the program, and wait() with it.
|
|
198
|
+
plaintext.close()
|
|
199
|
+
|
|
200
|
+
run.thread = threading.Thread(target=encrypt, daemon=True)
|
|
201
|
+
run.thread.start()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class DumpStream:
|
|
205
|
+
"""A dump as an iterator of byte chunks.
|
|
206
|
+
|
|
207
|
+
If the program fails or is killed, iteration raises :class:`TransferError`
|
|
208
|
+
at the end instead of stopping. :meth:`close` (or leaving a ``with`` block)
|
|
209
|
+
before the end stops the program, as does dropping the stream unfinished.
|
|
210
|
+
``result`` is set once iteration finishes.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
process: subprocess.Popen[bytes],
|
|
216
|
+
*,
|
|
217
|
+
tool: ClientTool,
|
|
218
|
+
server: ServerInfo,
|
|
219
|
+
verdict: Verdict,
|
|
220
|
+
recipients: Sequence[str] | None,
|
|
221
|
+
password: str,
|
|
222
|
+
on_finish: Any = None,
|
|
223
|
+
passphrase: str | None = None,
|
|
224
|
+
) -> None:
|
|
225
|
+
self._run = _DumpRun(process, verdict, on_finish)
|
|
226
|
+
self._tool = tool
|
|
227
|
+
self._server = server
|
|
228
|
+
self._verdict = verdict
|
|
229
|
+
self._password = password
|
|
230
|
+
self._started = time.monotonic()
|
|
231
|
+
self._done = False
|
|
232
|
+
self.result: TransferResult | None = None
|
|
233
|
+
self._finalizer = weakref.finalize(self, _stop, self._run)
|
|
234
|
+
if recipients is not None or passphrase is not None:
|
|
235
|
+
_start_encryption(self._run, recipients=recipients, passphrase=passphrase)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def pid(self) -> int:
|
|
239
|
+
"""The program's process id, for diagnostics."""
|
|
240
|
+
return self._run.process.pid
|
|
241
|
+
|
|
242
|
+
def __iter__(self) -> DumpStream:
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def __next__(self) -> bytes:
|
|
246
|
+
if self._done:
|
|
247
|
+
raise StopIteration
|
|
248
|
+
run = self._run
|
|
249
|
+
chunk = run.reader.read(CHUNK)
|
|
250
|
+
if chunk:
|
|
251
|
+
run.bytes += len(chunk)
|
|
252
|
+
return chunk
|
|
253
|
+
self._finish()
|
|
254
|
+
raise StopIteration
|
|
255
|
+
|
|
256
|
+
def _finish(self) -> None:
|
|
257
|
+
self._done = True
|
|
258
|
+
run = self._run
|
|
259
|
+
if run.thread is not None:
|
|
260
|
+
run.thread.join()
|
|
261
|
+
code = run.process.wait()
|
|
262
|
+
stderr = run.stderr.text(self._password)
|
|
263
|
+
run.reader.close()
|
|
264
|
+
self._finalizer.detach()
|
|
265
|
+
if code != 0 or run.error is not None:
|
|
266
|
+
if run.error is not None:
|
|
267
|
+
msg = f"dump failed: encryption: {run.error}"
|
|
268
|
+
else:
|
|
269
|
+
first = stderr.strip().splitlines()[0] if stderr.strip() else "no error output"
|
|
270
|
+
msg = f"dump failed: {self._tool.name} exited {code}: {first}"
|
|
271
|
+
# Backup often drops client stderr; keep a known cause in the message.
|
|
272
|
+
if "COLUMN_STATISTICS" in stderr:
|
|
273
|
+
msg += "; MySQL 8+ mysqldump against MariaDB needs --column-statistics=0"
|
|
274
|
+
error = TransferError(msg, self._verdict, code, stderr)
|
|
275
|
+
run.notify(error)
|
|
276
|
+
raise error from run.error
|
|
277
|
+
self.result = TransferResult(
|
|
278
|
+
run.bytes,
|
|
279
|
+
round((time.monotonic() - self._started) * 1000, 1),
|
|
280
|
+
self._tool.label,
|
|
281
|
+
self._server,
|
|
282
|
+
self._verdict,
|
|
283
|
+
)
|
|
284
|
+
run.notify(None)
|
|
285
|
+
|
|
286
|
+
def close(self) -> None:
|
|
287
|
+
"""Stop the program if it is still running."""
|
|
288
|
+
self._done = True
|
|
289
|
+
self._finalizer()
|
|
290
|
+
|
|
291
|
+
def __enter__(self) -> DumpStream:
|
|
292
|
+
return self
|
|
293
|
+
|
|
294
|
+
def __exit__(self, *exc: object) -> None:
|
|
295
|
+
self.close()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass(frozen=True, slots=True)
|
|
299
|
+
class _Plan:
|
|
300
|
+
verdict: Verdict
|
|
301
|
+
credentials: ResolvedCredentials | None
|
|
302
|
+
tool: ClientTool | None
|
|
303
|
+
server: ServerInfo | None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _platform_and_option_findings(options: Sequence[str], allowed: frozenset[str]) -> list[Finding]:
|
|
307
|
+
findings: list[Finding] = []
|
|
308
|
+
if not DEV_FD.is_dir():
|
|
309
|
+
findings.append(
|
|
310
|
+
Finding(Code.PLATFORM_UNSUPPORTED, "export and import need /dev/fd (Linux or macOS)")
|
|
311
|
+
)
|
|
312
|
+
findings += [
|
|
313
|
+
Finding(
|
|
314
|
+
Code.OPTION_NOT_ALLOWED,
|
|
315
|
+
f"{option.split('=', 1)[0]} is not an allowed option",
|
|
316
|
+
{"option": option.split("=", 1)[0]},
|
|
317
|
+
)
|
|
318
|
+
for option in _client.disallowed_options(options, allowed)
|
|
319
|
+
]
|
|
320
|
+
return findings
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _tool_findings(purpose: str, server: ServerInfo) -> tuple[ClientTool | None, list[Finding]]:
|
|
324
|
+
tool = _client.find_tool(purpose, server.family)
|
|
325
|
+
if tool is None:
|
|
326
|
+
names = ", ".join(_client.candidates(purpose, server.family))
|
|
327
|
+
return None, [
|
|
328
|
+
Finding(Code.CLIENT_TOOL_MISSING, f"none of {names} is on PATH", {"tools": names})
|
|
329
|
+
]
|
|
330
|
+
if tool.family is not server.family or tool.version[:2] < server.version[:2]:
|
|
331
|
+
return tool, [
|
|
332
|
+
Finding(
|
|
333
|
+
Code.CLIENT_SERVER_SKEW,
|
|
334
|
+
f"{tool.label} is older than, or a different family from, "
|
|
335
|
+
f"{server.family} {server.version_text}",
|
|
336
|
+
{"tool": tool.label, "server": server.version_text},
|
|
337
|
+
)
|
|
338
|
+
]
|
|
339
|
+
return tool, []
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _dump_plan(
|
|
343
|
+
connection: Connection, credentials: CredentialSource, options: Sequence[str]
|
|
344
|
+
) -> _Plan:
|
|
345
|
+
findings = _platform_and_option_findings(options, _client.DUMP_OPTIONS)
|
|
346
|
+
resolved = _resolve_or_finding(credentials)
|
|
347
|
+
if isinstance(resolved, Finding):
|
|
348
|
+
return _Plan(Verdict("dump", (*findings, resolved)), None, None, None)
|
|
349
|
+
verdict, session = _preflight_resolved(connection, resolved, "dump", DUMP_PRIVILEGES)
|
|
350
|
+
verdict = verdict.with_findings(*findings)
|
|
351
|
+
if session is None:
|
|
352
|
+
return _Plan(verdict, resolved, None, None)
|
|
353
|
+
database = connection.database
|
|
354
|
+
try:
|
|
355
|
+
with session, session.cursor() as cur:
|
|
356
|
+
server = detect_server(cur)
|
|
357
|
+
cur.execute(
|
|
358
|
+
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
|
|
359
|
+
(database,),
|
|
360
|
+
)
|
|
361
|
+
exists = int(cur.fetchone()[0]) > 0
|
|
362
|
+
stored = 0
|
|
363
|
+
if exists:
|
|
364
|
+
cur.execute(
|
|
365
|
+
"SELECT (SELECT COUNT(*) FROM information_schema.VIEWS"
|
|
366
|
+
" WHERE TABLE_SCHEMA = %s)"
|
|
367
|
+
" + (SELECT COUNT(*) FROM information_schema.TRIGGERS"
|
|
368
|
+
" WHERE TRIGGER_SCHEMA = %s)",
|
|
369
|
+
(database, database),
|
|
370
|
+
)
|
|
371
|
+
stored = int(cur.fetchone()[0])
|
|
372
|
+
except pymysql.err.MySQLError as exc:
|
|
373
|
+
raise _as_package_error(exc, DatabaseUnreachableError) from exc
|
|
374
|
+
if not exists:
|
|
375
|
+
verdict = verdict.with_findings(
|
|
376
|
+
Finding(Code.DATABASE_ABSENT, f"{database!r} does not exist")
|
|
377
|
+
)
|
|
378
|
+
elif stored:
|
|
379
|
+
verdict = verdict.with_findings(
|
|
380
|
+
Finding(
|
|
381
|
+
Code.STORED_OBJECTS_PRESENT,
|
|
382
|
+
f"{database!r} has {stored} view(s) or trigger(s); loading them elsewhere needs "
|
|
383
|
+
"privilege for their DEFINER, and MariaDB keeps their original text",
|
|
384
|
+
{"count": stored},
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
tool, tool_findings = _tool_findings(_client.PURPOSE_DUMP, server)
|
|
388
|
+
return _Plan(verdict.with_findings(*tool_findings), resolved, tool, server)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_dump(
|
|
392
|
+
connection: Connection, credentials: CredentialSource, *, options: Sequence[str] = ()
|
|
393
|
+
) -> Verdict:
|
|
394
|
+
"""What :func:`dump` would find, without running the dump."""
|
|
395
|
+
with operation("check_dump", connection) as fields:
|
|
396
|
+
verdict = _dump_plan(connection, credentials, options).verdict
|
|
397
|
+
fields[VERDICT_KEY] = verdict
|
|
398
|
+
return verdict
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def iter_dump(
|
|
402
|
+
connection: Connection,
|
|
403
|
+
credentials: CredentialSource,
|
|
404
|
+
*,
|
|
405
|
+
recipients: Sequence[str] | None = None,
|
|
406
|
+
passphrase: str | None = None,
|
|
407
|
+
options: Sequence[str] = (),
|
|
408
|
+
) -> DumpStream:
|
|
409
|
+
"""The dump as a :class:`DumpStream` of byte chunks (age-encrypted if requested)."""
|
|
410
|
+
# The operation outlives this call: a failure here ends it at once, and
|
|
411
|
+
# success hands it to the stream, which ends it when the dump does.
|
|
412
|
+
running = Running("dump", connection)
|
|
413
|
+
|
|
414
|
+
def finish(error: BaseException | None, count: int) -> None:
|
|
415
|
+
running.fields["bytes"] = count
|
|
416
|
+
running.end(error)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
with running.current() as fields:
|
|
420
|
+
return _start_dump(
|
|
421
|
+
connection, credentials, recipients, passphrase, options, fields, finish
|
|
422
|
+
)
|
|
423
|
+
except BaseException as exc:
|
|
424
|
+
running.end(exc)
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _start_dump(
|
|
429
|
+
connection: Connection,
|
|
430
|
+
credentials: CredentialSource,
|
|
431
|
+
recipients: Sequence[str] | None,
|
|
432
|
+
passphrase: str | None,
|
|
433
|
+
options: Sequence[str],
|
|
434
|
+
fields: dict[str, object],
|
|
435
|
+
finish: Any,
|
|
436
|
+
) -> DumpStream:
|
|
437
|
+
if recipients is not None and passphrase is not None:
|
|
438
|
+
msg = "supply recipients= or passphrase=, not both"
|
|
439
|
+
raise InvalidArgumentsError(msg)
|
|
440
|
+
if recipients is not None or passphrase is not None:
|
|
441
|
+
from . import encryption
|
|
442
|
+
|
|
443
|
+
if passphrase is not None:
|
|
444
|
+
encryption.check_passphrase(passphrase)
|
|
445
|
+
else:
|
|
446
|
+
encryption.check_recipients(recipients or ())
|
|
447
|
+
plan = _dump_plan(connection, credentials, options)
|
|
448
|
+
fields[VERDICT_KEY] = plan.verdict
|
|
449
|
+
if not plan.verdict.ok or plan.tool is None or plan.server is None or plan.credentials is None:
|
|
450
|
+
raise RefusedError(plan.verdict)
|
|
451
|
+
tool, server, creds = plan.tool, plan.server, plan.credentials
|
|
452
|
+
try:
|
|
453
|
+
process = _start(
|
|
454
|
+
tool,
|
|
455
|
+
_client.dump_args(
|
|
456
|
+
tool,
|
|
457
|
+
connection.database,
|
|
458
|
+
options,
|
|
459
|
+
server_family=server.family,
|
|
460
|
+
),
|
|
461
|
+
creds,
|
|
462
|
+
connection,
|
|
463
|
+
for_load=False,
|
|
464
|
+
)
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
msg = f"could not start {plan.tool.path}: {exc.strerror or exc}"
|
|
467
|
+
raise TransferError(msg, plan.verdict, None, "") from exc
|
|
468
|
+
try:
|
|
469
|
+
return DumpStream(
|
|
470
|
+
process,
|
|
471
|
+
tool=tool,
|
|
472
|
+
server=server,
|
|
473
|
+
verdict=plan.verdict,
|
|
474
|
+
recipients=recipients,
|
|
475
|
+
password=creds.password,
|
|
476
|
+
on_finish=finish,
|
|
477
|
+
passphrase=passphrase,
|
|
478
|
+
)
|
|
479
|
+
except BaseException:
|
|
480
|
+
_abandon(process)
|
|
481
|
+
raise
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@dataclass(frozen=True, slots=True)
|
|
485
|
+
class _Partial:
|
|
486
|
+
"""A private temporary file beside ``target``, renamed onto it on success."""
|
|
487
|
+
|
|
488
|
+
target: Path
|
|
489
|
+
path: Path
|
|
490
|
+
file: IO[bytes]
|
|
491
|
+
|
|
492
|
+
def discard(self) -> None:
|
|
493
|
+
self.file.close()
|
|
494
|
+
self.path.unlink(missing_ok=True)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _open_partial(target: Path) -> _Partial:
|
|
498
|
+
path = target.with_name(f".{target.name}.{uuid.uuid4().hex}.partial")
|
|
499
|
+
try:
|
|
500
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
501
|
+
except OSError as exc:
|
|
502
|
+
msg = f"cannot write the dump to {str(target)!r}: {exc.strerror}"
|
|
503
|
+
raise InvalidArgumentsError(msg) from exc
|
|
504
|
+
return _Partial(target, path, os.fdopen(fd, "wb"))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _write_failed(stream: DumpStream, where: str, exc: OSError) -> TransferError:
|
|
508
|
+
msg = f"writing the dump to {where} failed: {exc.strerror or exc}"
|
|
509
|
+
return TransferError(msg, stream._verdict, None, "")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _write_atomically(stream: DumpStream, partial: _Partial) -> None:
|
|
513
|
+
"""Write a stream into ``partial`` and rename it onto its target on success."""
|
|
514
|
+
try:
|
|
515
|
+
with partial.file as out:
|
|
516
|
+
for chunk in stream:
|
|
517
|
+
out.write(chunk)
|
|
518
|
+
out.flush()
|
|
519
|
+
os.fsync(out.fileno())
|
|
520
|
+
partial.path.replace(partial.target)
|
|
521
|
+
except BaseException as exc:
|
|
522
|
+
stream.close()
|
|
523
|
+
partial.discard()
|
|
524
|
+
if isinstance(exc, OSError):
|
|
525
|
+
raise _write_failed(stream, repr(str(partial.target)), exc) from exc
|
|
526
|
+
raise
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def dump(
|
|
530
|
+
connection: Connection,
|
|
531
|
+
credentials: CredentialSource,
|
|
532
|
+
to: IO[bytes] | str | os.PathLike[str],
|
|
533
|
+
*,
|
|
534
|
+
recipients: Sequence[str] | None = None,
|
|
535
|
+
passphrase: str | None = None,
|
|
536
|
+
options: Sequence[str] = (),
|
|
537
|
+
) -> TransferResult:
|
|
538
|
+
"""Dump to a writable binary stream, or atomically to a path (mode 0600)."""
|
|
539
|
+
if isinstance(to, io.TextIOBase):
|
|
540
|
+
msg = "dump writes bytes: pass a binary stream (sys.stdout.buffer, a file opened 'wb')"
|
|
541
|
+
raise InvalidArgumentsError(msg)
|
|
542
|
+
if isinstance(to, (str, os.PathLike)):
|
|
543
|
+
partial = _open_partial(Path(to))
|
|
544
|
+
try:
|
|
545
|
+
stream = iter_dump(
|
|
546
|
+
connection,
|
|
547
|
+
credentials,
|
|
548
|
+
recipients=recipients,
|
|
549
|
+
passphrase=passphrase,
|
|
550
|
+
options=options,
|
|
551
|
+
)
|
|
552
|
+
except BaseException:
|
|
553
|
+
partial.discard()
|
|
554
|
+
raise
|
|
555
|
+
_write_atomically(stream, partial)
|
|
556
|
+
else:
|
|
557
|
+
stream = iter_dump(
|
|
558
|
+
connection,
|
|
559
|
+
credentials,
|
|
560
|
+
recipients=recipients,
|
|
561
|
+
passphrase=passphrase,
|
|
562
|
+
options=options,
|
|
563
|
+
)
|
|
564
|
+
with stream:
|
|
565
|
+
try:
|
|
566
|
+
for chunk in stream:
|
|
567
|
+
to.write(chunk)
|
|
568
|
+
# Unflushed, the last chunk could still be lost after success is reported.
|
|
569
|
+
to.flush()
|
|
570
|
+
except OSError as exc:
|
|
571
|
+
stream.close()
|
|
572
|
+
raise _write_failed(stream, "the output stream", exc) from exc
|
|
573
|
+
if stream.result is None: # pragma: no cover - iteration always sets it or raises
|
|
574
|
+
msg = "dump finished without a result"
|
|
575
|
+
raise TransferError(msg, Verdict("dump"), None, "")
|
|
576
|
+
return stream.result
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
SANDBOX_LINE = b"/*M!999999\\- enable the sandbox mode */"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@dataclass(frozen=True, slots=True)
|
|
583
|
+
class _Header:
|
|
584
|
+
"""What the first lines of a source say, compared against fixed strings only."""
|
|
585
|
+
|
|
586
|
+
encrypted: bool
|
|
587
|
+
family: ServerFamily | None
|
|
588
|
+
sandboxed: bool
|
|
589
|
+
#: Encrypted, and no decryption secret was supplied, so the plaintext is unknown.
|
|
590
|
+
needs_identity: bool = False
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
#: Written by both families' tools a few lines below their banner, naming the source server.
|
|
594
|
+
SERVER_VERSION_LINE = b"-- Server version\t"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _family_of(line: bytes) -> ServerFamily:
|
|
598
|
+
return ServerFamily.MARIADB if b"MariaDB" in line else ServerFamily.MYSQL
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _header(plain_head: bytes | None, *, encrypted: bool) -> _Header:
|
|
602
|
+
if not plain_head:
|
|
603
|
+
return _Header(encrypted, None, False)
|
|
604
|
+
lines = plain_head.split(b"\n", 8)[:8]
|
|
605
|
+
sandboxed = lines[0].startswith(SANDBOX_LINE)
|
|
606
|
+
banner: ServerFamily | None = None
|
|
607
|
+
server: ServerFamily | None = None
|
|
608
|
+
for line in lines:
|
|
609
|
+
if banner is None and line.startswith((b"-- MariaDB dump", b"-- MySQL dump")):
|
|
610
|
+
banner = _family_of(line)
|
|
611
|
+
elif banner is not None and line.startswith(SERVER_VERSION_LINE):
|
|
612
|
+
# Either family's tool can dump the other's server; the server decides.
|
|
613
|
+
server = _family_of(line)
|
|
614
|
+
break
|
|
615
|
+
return _Header(encrypted, server or banner, sandboxed)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _not_bytes(block: object) -> InvalidArgumentsError:
|
|
619
|
+
kind = type(block).__name__
|
|
620
|
+
return InvalidArgumentsError(f"the dump source must yield bytes, not {kind}")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class _IterReader(io.RawIOBase):
|
|
624
|
+
def __init__(self, chunks: Iterable[bytes]) -> None:
|
|
625
|
+
self._chunks: Iterator[bytes] = iter(chunks)
|
|
626
|
+
# A view, so taking a piece of a large chunk never copies the rest of it.
|
|
627
|
+
self._pending = memoryview(b"")
|
|
628
|
+
|
|
629
|
+
def readable(self) -> bool:
|
|
630
|
+
return True
|
|
631
|
+
|
|
632
|
+
def readinto(self, buffer: Any) -> int:
|
|
633
|
+
while not self._pending:
|
|
634
|
+
try:
|
|
635
|
+
chunk = next(self._chunks)
|
|
636
|
+
except StopIteration:
|
|
637
|
+
return 0
|
|
638
|
+
try:
|
|
639
|
+
self._pending = memoryview(chunk)
|
|
640
|
+
except TypeError as exc:
|
|
641
|
+
raise _not_bytes(chunk) from exc
|
|
642
|
+
size = min(len(buffer), len(self._pending))
|
|
643
|
+
buffer[:size] = self._pending[:size]
|
|
644
|
+
self._pending = self._pending[size:]
|
|
645
|
+
return size
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _as_reader(chunks: Iterable[bytes]) -> IO[bytes]:
|
|
649
|
+
return io.BufferedReader(_IterReader(chunks))
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _prefixed(head: bytes, rest: IO[bytes]) -> IO[bytes]:
|
|
653
|
+
def chunks() -> Iterator[bytes]:
|
|
654
|
+
yield head
|
|
655
|
+
yield from iter(lambda: rest.read(CHUNK), b"")
|
|
656
|
+
|
|
657
|
+
return _as_reader(chunks())
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class _Decrypting:
|
|
661
|
+
"""Decrypts ``source`` into a pipe on a thread; ``reader`` is the plaintext end."""
|
|
662
|
+
|
|
663
|
+
def __init__(
|
|
664
|
+
self,
|
|
665
|
+
source: IO[bytes],
|
|
666
|
+
*,
|
|
667
|
+
identities: Sequence[str] | None = None,
|
|
668
|
+
passphrase: str | None = None,
|
|
669
|
+
) -> None:
|
|
670
|
+
from . import encryption
|
|
671
|
+
|
|
672
|
+
read_fd, write_fd = os.pipe()
|
|
673
|
+
self.reader: IO[bytes] = os.fdopen(read_fd, "rb")
|
|
674
|
+
self.error: BaseException | None = None
|
|
675
|
+
writer = os.fdopen(write_fd, "wb")
|
|
676
|
+
|
|
677
|
+
def run() -> None:
|
|
678
|
+
try:
|
|
679
|
+
if passphrase is not None:
|
|
680
|
+
encryption.decrypt(source, writer, passphrase=passphrase)
|
|
681
|
+
else:
|
|
682
|
+
encryption.decrypt(source, writer, identities or ())
|
|
683
|
+
except BrokenPipeError:
|
|
684
|
+
pass # the consumer stopped reading
|
|
685
|
+
except BaseException as exc: # noqa: BLE001 - re-raised on the consumer's thread
|
|
686
|
+
self.error = exc
|
|
687
|
+
finally:
|
|
688
|
+
with suppress(BrokenPipeError): # flushing to a consumer that stopped reading
|
|
689
|
+
writer.close()
|
|
690
|
+
|
|
691
|
+
self._thread = threading.Thread(target=run, daemon=True)
|
|
692
|
+
self._thread.start()
|
|
693
|
+
|
|
694
|
+
def join(self) -> None:
|
|
695
|
+
self._thread.join()
|
|
696
|
+
|
|
697
|
+
def close(self) -> None:
|
|
698
|
+
"""Stop reading; a thread blocked writing plaintext then fails with EPIPE and exits."""
|
|
699
|
+
self.reader.close()
|
|
700
|
+
self._thread.join(timeout=10)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _abandon(process: subprocess.Popen[bytes]) -> None:
|
|
704
|
+
"""Kill and reap a load program: closing its input instead would run a partial statement."""
|
|
705
|
+
process.kill()
|
|
706
|
+
process.wait()
|
|
707
|
+
if process.stdin is not None:
|
|
708
|
+
with suppress(OSError):
|
|
709
|
+
process.stdin.close()
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _io_failed(exc: OSError, verdict: Verdict) -> TransferError:
|
|
713
|
+
return TransferError(f"load failed: {exc.strerror or exc}", verdict, None, "")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _feed(stdin: IO[bytes], head: bytes, reader: IO[bytes], decrypt: _Decrypting | None) -> int:
|
|
717
|
+
"""Write ``head`` then ``reader`` to ``stdin`` and close it; return the bytes written."""
|
|
718
|
+
delivered = 0
|
|
719
|
+
for block in itertools.chain((head,), iter(lambda: reader.read(CHUNK), b"")):
|
|
720
|
+
if not block:
|
|
721
|
+
continue
|
|
722
|
+
try:
|
|
723
|
+
stdin.write(block)
|
|
724
|
+
except BrokenPipeError:
|
|
725
|
+
break # the program exited; its exit code says why
|
|
726
|
+
delivered += len(block)
|
|
727
|
+
if decrypt is not None:
|
|
728
|
+
# After an early exit the decrypting thread is blocked writing plaintext
|
|
729
|
+
# nobody reads; closing its reader is what lets it finish.
|
|
730
|
+
decrypt.close()
|
|
731
|
+
if decrypt.error is not None:
|
|
732
|
+
raise decrypt.error
|
|
733
|
+
with suppress(BrokenPipeError):
|
|
734
|
+
stdin.close()
|
|
735
|
+
return delivered
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _pump_load(
|
|
739
|
+
process: subprocess.Popen[bytes],
|
|
740
|
+
head: bytes,
|
|
741
|
+
reader: IO[bytes],
|
|
742
|
+
*,
|
|
743
|
+
decrypt: _Decrypting | None = None,
|
|
744
|
+
password: str,
|
|
745
|
+
verdict: Verdict | None = None,
|
|
746
|
+
) -> int:
|
|
747
|
+
"""Feed ``head`` then ``reader`` to the program's stdin; return the bytes delivered."""
|
|
748
|
+
if process.stdin is None or process.stderr is None:
|
|
749
|
+
msg = "a load process needs piped stdin and stderr"
|
|
750
|
+
raise ValueError(msg)
|
|
751
|
+
if verdict is None:
|
|
752
|
+
verdict = Verdict("load")
|
|
753
|
+
stderr = _Stderr(process.stderr)
|
|
754
|
+
# Every failure to feed and close stdin cleanly kills the program: otherwise its
|
|
755
|
+
# input would be closed later, on collection or exit, running a partial statement.
|
|
756
|
+
try:
|
|
757
|
+
delivered = _feed(process.stdin, head, reader, decrypt)
|
|
758
|
+
except BaseException as exc:
|
|
759
|
+
_abandon(process)
|
|
760
|
+
if decrypt is not None:
|
|
761
|
+
decrypt.close()
|
|
762
|
+
if isinstance(exc, OSError): # reading the source, or writing to the program
|
|
763
|
+
raise _io_failed(exc, verdict) from exc
|
|
764
|
+
raise
|
|
765
|
+
code = process.wait()
|
|
766
|
+
text = stderr.text(password)
|
|
767
|
+
if code != 0:
|
|
768
|
+
first = text.strip().splitlines()[0] if text.strip() else "no error output"
|
|
769
|
+
msg = f"load failed: exited {code}: {first}"
|
|
770
|
+
raise TransferError(msg, verdict, code, text)
|
|
771
|
+
return delivered
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _open_source(
|
|
775
|
+
source: IO[bytes] | Iterable[bytes] | str | os.PathLike[str], cleanup: ExitStack
|
|
776
|
+
) -> IO[bytes]:
|
|
777
|
+
if isinstance(source, (str, os.PathLike)):
|
|
778
|
+
try:
|
|
779
|
+
file = open(source, "rb") # noqa: PTH123, SIM115 - closed by cleanup
|
|
780
|
+
except OSError as exc:
|
|
781
|
+
msg = f"cannot read the dump source {os.fspath(source)!r}: {exc.strerror or exc}"
|
|
782
|
+
raise InvalidArgumentsError(msg) from exc
|
|
783
|
+
return cleanup.enter_context(file)
|
|
784
|
+
if isinstance(source, io.TextIOBase):
|
|
785
|
+
msg = "load reads bytes: pass a binary stream (sys.stdin.buffer, a file opened 'rb')"
|
|
786
|
+
raise InvalidArgumentsError(msg)
|
|
787
|
+
if hasattr(source, "read"):
|
|
788
|
+
return source # type: ignore[return-value]
|
|
789
|
+
return _as_reader(source)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _read_head(reader: IO[bytes]) -> bytes:
|
|
793
|
+
"""Up to CHUNK bytes; an unbuffered stream may return less per read."""
|
|
794
|
+
head = b""
|
|
795
|
+
while len(head) < CHUNK:
|
|
796
|
+
block = reader.read(CHUNK - len(head))
|
|
797
|
+
if not block:
|
|
798
|
+
break
|
|
799
|
+
if not isinstance(block, (bytes, bytearray, memoryview)):
|
|
800
|
+
raise _not_bytes(block)
|
|
801
|
+
head += block
|
|
802
|
+
return head
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _prepare(
|
|
806
|
+
reader: IO[bytes],
|
|
807
|
+
identities: Sequence[str] | None,
|
|
808
|
+
cleanup: ExitStack,
|
|
809
|
+
*,
|
|
810
|
+
passphrase: str | None = None,
|
|
811
|
+
) -> tuple[_Header, bytes, IO[bytes], _Decrypting | None]:
|
|
812
|
+
"""Peek the source: (header, plaintext head, plaintext reader, decryptor)."""
|
|
813
|
+
from . import encryption
|
|
814
|
+
|
|
815
|
+
if identities is not None and passphrase is not None:
|
|
816
|
+
msg = "supply identities= or passphrase=, not both"
|
|
817
|
+
raise InvalidArgumentsError(msg)
|
|
818
|
+
try:
|
|
819
|
+
head = _read_head(reader)
|
|
820
|
+
except OSError as exc:
|
|
821
|
+
msg = f"cannot read the dump source: {exc.strerror or exc}"
|
|
822
|
+
raise InvalidArgumentsError(msg) from exc
|
|
823
|
+
if not head.startswith(encryption.AGE_HEADER):
|
|
824
|
+
return _header(head, encrypted=False), head, reader, None
|
|
825
|
+
if identities is None and passphrase is None:
|
|
826
|
+
return _Header(True, None, False, needs_identity=True), b"", reader, None
|
|
827
|
+
decrypting = _Decrypting(_prefixed(head, reader), identities=identities, passphrase=passphrase)
|
|
828
|
+
cleanup.callback(decrypting.close)
|
|
829
|
+
plain_head = decrypting.reader.read(CHUNK)
|
|
830
|
+
if not plain_head:
|
|
831
|
+
decrypting.join()
|
|
832
|
+
error = decrypting.error
|
|
833
|
+
if isinstance(error, OSError):
|
|
834
|
+
msg = f"cannot read the dump source: {error.strerror or error}"
|
|
835
|
+
raise InvalidArgumentsError(msg) from error
|
|
836
|
+
if error is not None:
|
|
837
|
+
raise error # wrong identity/passphrase or damage, before anything is loaded
|
|
838
|
+
return _header(plain_head, encrypted=True), plain_head, decrypting.reader, decrypting
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _load_plan(
|
|
842
|
+
connection: Connection,
|
|
843
|
+
credentials: CredentialSource,
|
|
844
|
+
options: Sequence[str],
|
|
845
|
+
*,
|
|
846
|
+
replace: str | None,
|
|
847
|
+
header: _Header | None,
|
|
848
|
+
allow_unsandboxed: bool,
|
|
849
|
+
) -> _Plan:
|
|
850
|
+
findings = _platform_and_option_findings(options, _client.LOAD_OPTIONS)
|
|
851
|
+
if header is not None and header.needs_identity:
|
|
852
|
+
findings.append(
|
|
853
|
+
Finding(
|
|
854
|
+
Code.IDENTITY_REQUIRED,
|
|
855
|
+
"the source is age-encrypted; supply identities= or passphrase=",
|
|
856
|
+
)
|
|
857
|
+
)
|
|
858
|
+
resolved = _resolve_or_finding(credentials)
|
|
859
|
+
if isinstance(resolved, Finding):
|
|
860
|
+
return _Plan(Verdict("load", (*findings, resolved)), None, None, None)
|
|
861
|
+
verdict, session = _preflight_resolved(connection, resolved, "load", LOAD_PRIVILEGES)
|
|
862
|
+
verdict = verdict.with_findings(*findings)
|
|
863
|
+
if session is None:
|
|
864
|
+
return _Plan(verdict, resolved, None, None)
|
|
865
|
+
database = connection.database
|
|
866
|
+
try:
|
|
867
|
+
with session, session.cursor() as cur:
|
|
868
|
+
server = detect_server(cur)
|
|
869
|
+
cur.execute(
|
|
870
|
+
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
|
|
871
|
+
(database,),
|
|
872
|
+
)
|
|
873
|
+
exists = int(cur.fetchone()[0]) > 0
|
|
874
|
+
tables = 0
|
|
875
|
+
if exists:
|
|
876
|
+
cur.execute(
|
|
877
|
+
"SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s",
|
|
878
|
+
(database,),
|
|
879
|
+
)
|
|
880
|
+
tables = int(cur.fetchone()[0])
|
|
881
|
+
except pymysql.err.MySQLError as exc:
|
|
882
|
+
raise _as_package_error(exc, DatabaseUnreachableError) from exc
|
|
883
|
+
extra: list[Finding] = []
|
|
884
|
+
if not exists:
|
|
885
|
+
extra.append(Finding(Code.DATABASE_ABSENT, f"{database!r} does not exist"))
|
|
886
|
+
elif replace is not None:
|
|
887
|
+
extra += _confirmation(database, replace)
|
|
888
|
+
elif tables:
|
|
889
|
+
extra.append(
|
|
890
|
+
Finding(
|
|
891
|
+
Code.DATABASE_NOT_EMPTY,
|
|
892
|
+
f"{database!r} holds {tables} table(s); "
|
|
893
|
+
f"pass replace={database!r} to load over them",
|
|
894
|
+
{"tables": tables},
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
tool, tool_findings = _tool_findings(_client.PURPOSE_LOAD, server)
|
|
898
|
+
extra += tool_findings
|
|
899
|
+
if header is not None and tool is not None:
|
|
900
|
+
if header.family is not None and header.family is not server.family:
|
|
901
|
+
extra.append(
|
|
902
|
+
Finding(
|
|
903
|
+
Code.CROSS_FAMILY_DUMP,
|
|
904
|
+
f"the dump comes from {header.family}; its collations may not exist on "
|
|
905
|
+
f"{server.family} {server.version_text}",
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
if header.sandboxed and not tool.understands_sandbox_line and not allow_unsandboxed:
|
|
909
|
+
extra.append(
|
|
910
|
+
Finding(
|
|
911
|
+
Code.INCOMPATIBLE_DUMP_SOURCE,
|
|
912
|
+
f"the dump begins with MariaDB's sandbox line, which {tool.label} cannot "
|
|
913
|
+
"read; pass allow_unsandboxed=True to strip that one line",
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
return _Plan(verdict.with_findings(*extra), resolved, tool, server)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def validate_table_prefix(prefix: str) -> str:
|
|
920
|
+
"""Return ``prefix`` if it is safe to LIKE-match and drop against, else raise."""
|
|
921
|
+
if not isinstance(prefix, str) or not prefix:
|
|
922
|
+
msg = "table prefix must be a non-empty string"
|
|
923
|
+
raise InvalidArgumentsError(msg)
|
|
924
|
+
if len(prefix) > MAX_TABLE_PREFIX:
|
|
925
|
+
msg = f"table prefix longer than {MAX_TABLE_PREFIX} characters"
|
|
926
|
+
raise InvalidArgumentsError(msg)
|
|
927
|
+
if prefix != prefix.strip():
|
|
928
|
+
msg = "table prefix has leading or trailing whitespace"
|
|
929
|
+
raise InvalidArgumentsError(msg)
|
|
930
|
+
found = _PREFIX_FORBIDDEN.intersection(prefix)
|
|
931
|
+
if found:
|
|
932
|
+
chars = ", ".join(repr(c) for c in sorted(found))
|
|
933
|
+
msg = f"table prefix contains forbidden characters: {chars}"
|
|
934
|
+
raise InvalidArgumentsError(msg)
|
|
935
|
+
return prefix
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _prefix_like(prefix: str) -> str:
|
|
939
|
+
"""``prefix`` as a LIKE pattern that matches only names starting with it literally."""
|
|
940
|
+
return grant_pattern(prefix) + "%"
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _list_prefix_objects(cur: Any, database: str, prefix: str) -> list[tuple[str, str]]:
|
|
944
|
+
"""``(name, TABLE_TYPE)`` for base tables and views under ``prefix``, views first."""
|
|
945
|
+
cur.execute(
|
|
946
|
+
"SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES "
|
|
947
|
+
"WHERE TABLE_SCHEMA = %s AND TABLE_TYPE IN ('BASE TABLE', 'VIEW') "
|
|
948
|
+
"AND TABLE_NAME LIKE %s ESCAPE %s "
|
|
949
|
+
"ORDER BY CASE TABLE_TYPE WHEN 'VIEW' THEN 0 ELSE 1 END, TABLE_NAME",
|
|
950
|
+
(database, _prefix_like(prefix), "\\"),
|
|
951
|
+
)
|
|
952
|
+
return [(str(name), str(kind)) for name, kind in cur.fetchall()]
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _drop_prefix_objects(cur: Any, database: str, prefix: str) -> tuple[str, ...]:
|
|
956
|
+
"""Drop matching views then base tables; return the names dropped, in that order."""
|
|
957
|
+
rows = _list_prefix_objects(cur, database, prefix)
|
|
958
|
+
dropped: list[str] = []
|
|
959
|
+
for name, kind in rows:
|
|
960
|
+
statement = "VIEW" if kind == "VIEW" else "TABLE"
|
|
961
|
+
cur.execute(
|
|
962
|
+
f"DROP {statement} IF EXISTS " # noqa: S608 - quoted identifiers only
|
|
963
|
+
f"{quote_identifier(database)}.{quote_identifier(name)}"
|
|
964
|
+
)
|
|
965
|
+
dropped.append(name)
|
|
966
|
+
return tuple(dropped)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _drop_tables_plan(
|
|
970
|
+
connection: Connection, credentials: CredentialSource, prefix: str
|
|
971
|
+
) -> tuple[Verdict, ResolvedCredentials | None, tuple[str, ...], Any | None]:
|
|
972
|
+
"""Preflight plus the matching table/view list. Session is open when the verdict is ok."""
|
|
973
|
+
prefix = validate_table_prefix(prefix)
|
|
974
|
+
resolved = _resolve_or_finding(credentials)
|
|
975
|
+
if isinstance(resolved, Finding):
|
|
976
|
+
return Verdict("drop_tables", (resolved,)), None, (), None
|
|
977
|
+
verdict, session = _preflight_resolved(
|
|
978
|
+
connection, resolved, "drop_tables", DROP_TABLES_PRIVILEGES
|
|
979
|
+
)
|
|
980
|
+
if session is None:
|
|
981
|
+
return verdict, resolved, (), None
|
|
982
|
+
database = connection.database
|
|
983
|
+
try:
|
|
984
|
+
with session.cursor() as cur:
|
|
985
|
+
cur.execute(
|
|
986
|
+
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
|
|
987
|
+
(database,),
|
|
988
|
+
)
|
|
989
|
+
exists = int(cur.fetchone()[0]) > 0
|
|
990
|
+
tables: tuple[str, ...] = ()
|
|
991
|
+
if exists:
|
|
992
|
+
tables = tuple(name for name, _ in _list_prefix_objects(cur, database, prefix))
|
|
993
|
+
else:
|
|
994
|
+
verdict = verdict.with_findings(
|
|
995
|
+
Finding(Code.DATABASE_ABSENT, f"{database!r} does not exist")
|
|
996
|
+
)
|
|
997
|
+
except pymysql.err.MySQLError as exc:
|
|
998
|
+
session.close()
|
|
999
|
+
raise _as_package_error(exc, DatabaseUnreachableError) from exc
|
|
1000
|
+
if not verdict.ok:
|
|
1001
|
+
session.close()
|
|
1002
|
+
return verdict, resolved, tables, None
|
|
1003
|
+
return verdict, resolved, tables, session
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def check_drop_tables(
|
|
1007
|
+
connection: Connection, credentials: CredentialSource, *, prefix: str
|
|
1008
|
+
) -> DropTablesResult:
|
|
1009
|
+
"""What :func:`drop_tables` would drop. ``tables`` is the match list."""
|
|
1010
|
+
with operation("check_drop_tables", connection) as fields:
|
|
1011
|
+
verdict, _, tables, session = _drop_tables_plan(connection, credentials, prefix)
|
|
1012
|
+
if session is not None:
|
|
1013
|
+
session.close()
|
|
1014
|
+
fields[VERDICT_KEY] = verdict
|
|
1015
|
+
fields["tables"] = len(tables)
|
|
1016
|
+
return DropTablesResult(tables, verdict)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def drop_tables(
|
|
1020
|
+
connection: Connection, credentials: CredentialSource, *, prefix: str
|
|
1021
|
+
) -> DropTablesResult:
|
|
1022
|
+
"""Drop every base table and view in this database whose name starts with ``prefix``.
|
|
1023
|
+
|
|
1024
|
+
``_`` and ``%`` in ``prefix`` are matched literally. Names that do not start
|
|
1025
|
+
with the prefix are never touched. An empty or invalid prefix is refused.
|
|
1026
|
+
"""
|
|
1027
|
+
with operation("drop_tables", connection) as fields:
|
|
1028
|
+
verdict, _, _, session = _drop_tables_plan(connection, credentials, prefix)
|
|
1029
|
+
if session is None:
|
|
1030
|
+
raise RefusedError(verdict)
|
|
1031
|
+
try:
|
|
1032
|
+
with session, session.cursor() as cur:
|
|
1033
|
+
dropped = _drop_prefix_objects(
|
|
1034
|
+
cur, connection.database, validate_table_prefix(prefix)
|
|
1035
|
+
)
|
|
1036
|
+
session.commit()
|
|
1037
|
+
except pymysql.err.MySQLError as exc:
|
|
1038
|
+
msg = f"dropping tables with prefix {prefix!r} failed"
|
|
1039
|
+
raise _as_package_error(exc, DatabaseError, msg) from exc
|
|
1040
|
+
fields[VERDICT_KEY] = verdict
|
|
1041
|
+
fields["tables"] = len(dropped)
|
|
1042
|
+
return DropTablesResult(dropped, verdict)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def check_load(
|
|
1046
|
+
connection: Connection,
|
|
1047
|
+
credentials: CredentialSource,
|
|
1048
|
+
*,
|
|
1049
|
+
source: str | os.PathLike[str] | None = None,
|
|
1050
|
+
replace: str | None = None,
|
|
1051
|
+
prune_prefix: str | None = None,
|
|
1052
|
+
identities: Sequence[str] | None = None,
|
|
1053
|
+
passphrase: str | None = None,
|
|
1054
|
+
options: Sequence[str] = (),
|
|
1055
|
+
allow_unsandboxed: bool = False,
|
|
1056
|
+
) -> Verdict:
|
|
1057
|
+
"""What :func:`load` would find. The source header is read only for a path."""
|
|
1058
|
+
if prune_prefix is not None:
|
|
1059
|
+
validate_table_prefix(prune_prefix)
|
|
1060
|
+
with operation("check_load", connection) as fields, ExitStack() as cleanup:
|
|
1061
|
+
if source is not None and not isinstance(source, (str, os.PathLike)):
|
|
1062
|
+
# Reading a stream's head here would leave load() a dump without its head.
|
|
1063
|
+
msg = "check_load takes source only as a path; a stream cannot be read twice"
|
|
1064
|
+
raise InvalidArgumentsError(msg)
|
|
1065
|
+
header = None
|
|
1066
|
+
if source is not None:
|
|
1067
|
+
header, *_ = _prepare(
|
|
1068
|
+
_open_source(source, cleanup), identities, cleanup, passphrase=passphrase
|
|
1069
|
+
)
|
|
1070
|
+
verdict = _load_plan(
|
|
1071
|
+
connection,
|
|
1072
|
+
credentials,
|
|
1073
|
+
options,
|
|
1074
|
+
replace=replace,
|
|
1075
|
+
header=header,
|
|
1076
|
+
allow_unsandboxed=allow_unsandboxed,
|
|
1077
|
+
).verdict
|
|
1078
|
+
fields[VERDICT_KEY] = verdict
|
|
1079
|
+
return verdict
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def load(
|
|
1083
|
+
connection: Connection,
|
|
1084
|
+
credentials: CredentialSource,
|
|
1085
|
+
source: IO[bytes] | Iterable[bytes] | str | os.PathLike[str],
|
|
1086
|
+
*,
|
|
1087
|
+
replace: str | None = None,
|
|
1088
|
+
prune_prefix: str | None = None,
|
|
1089
|
+
identities: Sequence[str] | None = None,
|
|
1090
|
+
passphrase: str | None = None,
|
|
1091
|
+
options: Sequence[str] = (),
|
|
1092
|
+
allow_unsandboxed: bool = False,
|
|
1093
|
+
) -> TransferResult:
|
|
1094
|
+
"""Load a dump into this database.
|
|
1095
|
+
|
|
1096
|
+
When ``prune_prefix`` is set, every base table and view whose name starts
|
|
1097
|
+
with that prefix is dropped first (same rules as :func:`drop_tables`); their
|
|
1098
|
+
names are reported on :attr:`TransferResult.dropped`. Damage in a source is
|
|
1099
|
+
found mid-stream after earlier statements ran — take a safety dump first if
|
|
1100
|
+
the target must be recoverable.
|
|
1101
|
+
"""
|
|
1102
|
+
if prune_prefix is not None:
|
|
1103
|
+
prune_prefix = validate_table_prefix(prune_prefix)
|
|
1104
|
+
started = time.monotonic()
|
|
1105
|
+
with operation("load", connection) as fields, ExitStack() as cleanup:
|
|
1106
|
+
header, head, reader, decrypting = _prepare(
|
|
1107
|
+
_open_source(source, cleanup), identities, cleanup, passphrase=passphrase
|
|
1108
|
+
)
|
|
1109
|
+
plan = _load_plan(
|
|
1110
|
+
connection,
|
|
1111
|
+
credentials,
|
|
1112
|
+
options,
|
|
1113
|
+
replace=replace,
|
|
1114
|
+
header=header,
|
|
1115
|
+
allow_unsandboxed=allow_unsandboxed,
|
|
1116
|
+
)
|
|
1117
|
+
fields[VERDICT_KEY] = plan.verdict
|
|
1118
|
+
if (
|
|
1119
|
+
not plan.verdict.ok
|
|
1120
|
+
or plan.tool is None
|
|
1121
|
+
or plan.server is None
|
|
1122
|
+
or plan.credentials is None
|
|
1123
|
+
):
|
|
1124
|
+
raise RefusedError(plan.verdict)
|
|
1125
|
+
dropped: tuple[str, ...] = ()
|
|
1126
|
+
if prune_prefix is not None:
|
|
1127
|
+
try:
|
|
1128
|
+
session = open_connection(connection, plan.credentials, database=None)
|
|
1129
|
+
with session, session.cursor() as cur:
|
|
1130
|
+
dropped = _drop_prefix_objects(cur, connection.database, prune_prefix)
|
|
1131
|
+
session.commit()
|
|
1132
|
+
except pymysql.err.MySQLError as exc:
|
|
1133
|
+
msg = f"pruning tables with prefix {prune_prefix!r} before load failed"
|
|
1134
|
+
raise _as_package_error(exc, DatabaseError, msg) from exc
|
|
1135
|
+
fields["dropped"] = len(dropped)
|
|
1136
|
+
if header.sandboxed and not plan.tool.understands_sandbox_line:
|
|
1137
|
+
head = head.split(b"\n", 1)[1] if b"\n" in head else b""
|
|
1138
|
+
try:
|
|
1139
|
+
process = _start(
|
|
1140
|
+
plan.tool,
|
|
1141
|
+
_client.load_args(connection.database, options),
|
|
1142
|
+
plan.credentials,
|
|
1143
|
+
connection,
|
|
1144
|
+
for_load=True,
|
|
1145
|
+
)
|
|
1146
|
+
except OSError as exc:
|
|
1147
|
+
msg = f"could not start {plan.tool.path}: {exc.strerror or exc}"
|
|
1148
|
+
raise TransferError(msg, plan.verdict, None, "") from exc
|
|
1149
|
+
delivered = _pump_load(
|
|
1150
|
+
process,
|
|
1151
|
+
head,
|
|
1152
|
+
reader,
|
|
1153
|
+
decrypt=decrypting,
|
|
1154
|
+
password=plan.credentials.password,
|
|
1155
|
+
verdict=plan.verdict,
|
|
1156
|
+
)
|
|
1157
|
+
fields["bytes"] = delivered
|
|
1158
|
+
return TransferResult(
|
|
1159
|
+
delivered,
|
|
1160
|
+
round((time.monotonic() - started) * 1000, 1),
|
|
1161
|
+
plan.tool.label,
|
|
1162
|
+
plan.server,
|
|
1163
|
+
plan.verdict,
|
|
1164
|
+
dropped,
|
|
1165
|
+
)
|