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,237 @@
|
|
|
1
|
+
"""Passphrase age streaming: binder preferred, age+batchpass CLI fallback.
|
|
2
|
+
|
|
3
|
+
Selection via ``L3IO_AGE_PASSPHRASE_BACKEND``:
|
|
4
|
+
|
|
5
|
+
- ``auto`` (default): binder if importable, else CLI if ``age`` and
|
|
6
|
+
``age-plugin-batchpass`` are on ``PATH``
|
|
7
|
+
- ``binder`` / ``cli``: that backend only
|
|
8
|
+
|
|
9
|
+
Passphrases never appear on argv. The CLI path uses ``AGE_PASSPHRASE_FD``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
from typing import IO, Any, Literal
|
|
20
|
+
|
|
21
|
+
from .errors import CorruptDumpError, DecryptionError, InvalidArgumentsError, MissingDependencyError
|
|
22
|
+
|
|
23
|
+
BackendName = Literal["binder", "cli"]
|
|
24
|
+
_ENV = "L3IO_AGE_PASSPHRASE_BACKEND"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _requested() -> str:
|
|
28
|
+
return os.environ.get(_ENV, "auto").strip().lower() or "auto"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def binder_available() -> bool:
|
|
32
|
+
try:
|
|
33
|
+
import l3io_age_passphrase # noqa: F401
|
|
34
|
+
except ImportError:
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cli_available() -> bool:
|
|
40
|
+
from ._client import which_absolute
|
|
41
|
+
|
|
42
|
+
return which_absolute("age") is not None and which_absolute("age-plugin-batchpass") is not None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_backend() -> BackendName:
|
|
46
|
+
choice = _requested()
|
|
47
|
+
if choice not in {"auto", "binder", "cli"}:
|
|
48
|
+
msg = f"{_ENV} must be auto, binder, or cli (got {choice!r})"
|
|
49
|
+
raise InvalidArgumentsError(msg)
|
|
50
|
+
if choice == "binder":
|
|
51
|
+
if not binder_available():
|
|
52
|
+
raise MissingDependencyError(extra="encryption", distribution="l3io-age-passphrase")
|
|
53
|
+
return "binder"
|
|
54
|
+
if choice == "cli":
|
|
55
|
+
if not cli_available():
|
|
56
|
+
msg = (
|
|
57
|
+
"passphrase CLI backend needs age >=1.3 and age-plugin-batchpass on PATH "
|
|
58
|
+
f"(set {_ENV}=binder to use the in-process binder instead)"
|
|
59
|
+
)
|
|
60
|
+
raise InvalidArgumentsError(msg)
|
|
61
|
+
return "cli"
|
|
62
|
+
if binder_available():
|
|
63
|
+
return "binder"
|
|
64
|
+
if cli_available():
|
|
65
|
+
return "cli"
|
|
66
|
+
raise MissingDependencyError(extra="encryption", distribution="l3io-age-passphrase")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_ready(passphrase: str) -> None:
|
|
70
|
+
if passphrase == "":
|
|
71
|
+
msg = "passphrase must not be empty"
|
|
72
|
+
raise InvalidArgumentsError(msg)
|
|
73
|
+
resolve_backend()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def encrypt_io(reader: IO[bytes], writer: IO[bytes], passphrase: str) -> None:
|
|
77
|
+
backend = resolve_backend()
|
|
78
|
+
if backend == "binder":
|
|
79
|
+
_binder_encrypt(reader, writer, passphrase)
|
|
80
|
+
else:
|
|
81
|
+
_cli_crypt(reader, writer, passphrase, encrypt=True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decrypt_io(reader: IO[bytes], writer: IO[bytes], passphrase: str) -> None:
|
|
85
|
+
backend = resolve_backend()
|
|
86
|
+
if backend == "binder":
|
|
87
|
+
_binder_decrypt(reader, writer, passphrase)
|
|
88
|
+
else:
|
|
89
|
+
_cli_crypt(reader, writer, passphrase, encrypt=False)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _binder() -> Any:
|
|
93
|
+
try:
|
|
94
|
+
import l3io_age_passphrase
|
|
95
|
+
except ImportError as exc:
|
|
96
|
+
raise MissingDependencyError(
|
|
97
|
+
extra="encryption",
|
|
98
|
+
distribution="l3io-age-passphrase",
|
|
99
|
+
) from exc
|
|
100
|
+
return l3io_age_passphrase
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _binder_encrypt(reader: IO[bytes], writer: IO[bytes], passphrase: str) -> None:
|
|
104
|
+
mod = _binder()
|
|
105
|
+
try:
|
|
106
|
+
mod.encrypt_io(reader, writer, passphrase)
|
|
107
|
+
except mod.EncryptError as exc:
|
|
108
|
+
if str(exc).startswith("BrokenPipeError"):
|
|
109
|
+
raise BrokenPipeError(str(exc)) from exc
|
|
110
|
+
raise OSError(str(exc)) from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _binder_decrypt(reader: IO[bytes], writer: IO[bytes], passphrase: str) -> None:
|
|
114
|
+
mod = _binder()
|
|
115
|
+
try:
|
|
116
|
+
mod.decrypt_io(reader, writer, passphrase)
|
|
117
|
+
except BrokenPipeError:
|
|
118
|
+
raise
|
|
119
|
+
except mod.DecryptError as exc:
|
|
120
|
+
text = str(exc)
|
|
121
|
+
if text == "Decryption failed":
|
|
122
|
+
msg = "the passphrase cannot decrypt this dump"
|
|
123
|
+
raise DecryptionError(msg) from exc
|
|
124
|
+
msg = "the encrypted dump is damaged or not an age file"
|
|
125
|
+
raise CorruptDumpError(msg) from exc
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
if type(exc) is not OSError or exc.errno is not None:
|
|
128
|
+
raise
|
|
129
|
+
msg = "the encrypted dump is truncated or damaged"
|
|
130
|
+
raise CorruptDumpError(msg) from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _open_passphrase_fd(passphrase: str) -> int:
|
|
134
|
+
"""Write the passphrase to a pipe; return the inheritable read FD."""
|
|
135
|
+
read_fd, write_fd = os.pipe()
|
|
136
|
+
try:
|
|
137
|
+
os.write(write_fd, passphrase.encode("utf-8") + b"\n")
|
|
138
|
+
finally:
|
|
139
|
+
os.close(write_fd)
|
|
140
|
+
os.set_inheritable(read_fd, True)
|
|
141
|
+
return read_fd
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cli_crypt(
|
|
145
|
+
reader: IO[bytes],
|
|
146
|
+
writer: IO[bytes],
|
|
147
|
+
passphrase: str,
|
|
148
|
+
*,
|
|
149
|
+
encrypt: bool,
|
|
150
|
+
) -> None:
|
|
151
|
+
from ._client import age_child_env, which_absolute
|
|
152
|
+
|
|
153
|
+
age_bin = which_absolute("age")
|
|
154
|
+
plugin = which_absolute("age-plugin-batchpass")
|
|
155
|
+
if age_bin is None or plugin is None:
|
|
156
|
+
msg = "age and age-plugin-batchpass must be on PATH for the CLI passphrase backend"
|
|
157
|
+
raise InvalidArgumentsError(msg)
|
|
158
|
+
|
|
159
|
+
pass_fd = _open_passphrase_fd(passphrase)
|
|
160
|
+
env = age_child_env(passphrase_fd=pass_fd)
|
|
161
|
+
args = [age_bin, "-e" if encrypt else "-d", "-j", "batchpass"]
|
|
162
|
+
try:
|
|
163
|
+
proc = subprocess.Popen( # noqa: S603 - absolute age from PATH, fixed args
|
|
164
|
+
args,
|
|
165
|
+
stdin=subprocess.PIPE,
|
|
166
|
+
stdout=subprocess.PIPE,
|
|
167
|
+
stderr=subprocess.PIPE,
|
|
168
|
+
pass_fds=(pass_fd,),
|
|
169
|
+
env=env,
|
|
170
|
+
close_fds=True,
|
|
171
|
+
)
|
|
172
|
+
finally:
|
|
173
|
+
os.close(pass_fd)
|
|
174
|
+
|
|
175
|
+
stdin = proc.stdin
|
|
176
|
+
stdout = proc.stdout
|
|
177
|
+
stderr_pipe = proc.stderr
|
|
178
|
+
if stdin is None or stdout is None or stderr_pipe is None:
|
|
179
|
+
proc.kill()
|
|
180
|
+
msg = "age subprocess pipes were not created"
|
|
181
|
+
raise OSError(msg)
|
|
182
|
+
|
|
183
|
+
write_error: list[BaseException] = []
|
|
184
|
+
|
|
185
|
+
def _pump_in() -> None:
|
|
186
|
+
try:
|
|
187
|
+
shutil.copyfileobj(reader, stdin)
|
|
188
|
+
stdin.close()
|
|
189
|
+
except BaseException as exc: # noqa: BLE001 - surface on join
|
|
190
|
+
write_error.append(exc)
|
|
191
|
+
with contextlib.suppress(OSError):
|
|
192
|
+
stdin.close()
|
|
193
|
+
|
|
194
|
+
inlet = threading.Thread(target=_pump_in, name="age-passphrase-stdin", daemon=True)
|
|
195
|
+
inlet.start()
|
|
196
|
+
try:
|
|
197
|
+
shutil.copyfileobj(stdout, writer)
|
|
198
|
+
finally:
|
|
199
|
+
stdout.close()
|
|
200
|
+
inlet.join(timeout=3600)
|
|
201
|
+
stderr = stderr_pipe.read()
|
|
202
|
+
stderr_pipe.close()
|
|
203
|
+
code = proc.wait(timeout=60)
|
|
204
|
+
|
|
205
|
+
if write_error:
|
|
206
|
+
exc = write_error[0]
|
|
207
|
+
if isinstance(exc, BrokenPipeError):
|
|
208
|
+
raise BrokenPipeError(str(exc)) from exc
|
|
209
|
+
if isinstance(exc, OSError):
|
|
210
|
+
raise exc
|
|
211
|
+
raise OSError(str(exc)) from exc
|
|
212
|
+
|
|
213
|
+
if code == 0:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
err = stderr.decode("utf-8", errors="replace")
|
|
217
|
+
_map_cli_failure(encrypt=encrypt, err=err, code=code)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _map_cli_failure(*, encrypt: bool, err: str, code: int) -> None:
|
|
221
|
+
lower = err.lower()
|
|
222
|
+
if not encrypt:
|
|
223
|
+
if "no identity matched" in lower or "incorrect" in lower or "wrong" in lower:
|
|
224
|
+
msg = "the passphrase cannot decrypt this dump"
|
|
225
|
+
raise DecryptionError(msg)
|
|
226
|
+
if "failed to read header" in lower or "not an age" in lower or "parsing" in lower:
|
|
227
|
+
msg = "the encrypted dump is damaged or not an age file"
|
|
228
|
+
raise CorruptDumpError(msg)
|
|
229
|
+
# Truncated STREAM bodies often surface as read/MAC errors.
|
|
230
|
+
if "unexpected eof" in lower or "mac" in lower or "truncated" in lower:
|
|
231
|
+
msg = "the encrypted dump is truncated or damaged"
|
|
232
|
+
raise CorruptDumpError(msg)
|
|
233
|
+
msg = "the encrypted dump is damaged or not an age file"
|
|
234
|
+
raise CorruptDumpError(msg)
|
|
235
|
+
detail = err.strip() or "unknown error"
|
|
236
|
+
msg = f"age passphrase encrypt failed (exit {code}): {detail}"
|
|
237
|
+
raise OSError(msg)
|
l3io/wp/database/_sql.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Opening a server connection and classifying driver errors. Internal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pymysql
|
|
6
|
+
|
|
7
|
+
from .connection import Connection
|
|
8
|
+
from .credentials import ResolvedCredentials
|
|
9
|
+
from .state import ConnectionState
|
|
10
|
+
|
|
11
|
+
# BaseException is used by classify so SSLError (not a MySQLError) maps to TLS_FAILED.
|
|
12
|
+
|
|
13
|
+
CONNECT_TIMEOUT_SECONDS = 10
|
|
14
|
+
|
|
15
|
+
ER_DBACCESS_DENIED = 1044
|
|
16
|
+
ER_ACCESS_DENIED = 1045
|
|
17
|
+
ER_BAD_DB = 1049
|
|
18
|
+
ER_NONEXISTING_GRANT = 1141
|
|
19
|
+
ER_TABLEACCESS_DENIED = 1142
|
|
20
|
+
ER_CANNOT_USER = 1396
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def open_connection(
|
|
24
|
+
connection: Connection, credentials: ResolvedCredentials, *, database: str | None
|
|
25
|
+
) -> pymysql.connections.Connection:
|
|
26
|
+
"""Open a connection. Credentials are call arguments, never a URI.
|
|
27
|
+
|
|
28
|
+
Auth plugins that would send or prompt for a cleartext password are refused
|
|
29
|
+
via ``auth_plugin_map`` (see :mod:`.auth`).
|
|
30
|
+
"""
|
|
31
|
+
from .auth import auth_plugin_map
|
|
32
|
+
from .tls import pymysql_ssl
|
|
33
|
+
|
|
34
|
+
ssl_ctx = pymysql_ssl(connection.tls)
|
|
35
|
+
return pymysql.connect(
|
|
36
|
+
host=connection.host,
|
|
37
|
+
port=connection.port,
|
|
38
|
+
user=credentials.username,
|
|
39
|
+
password=credentials.password,
|
|
40
|
+
database=database,
|
|
41
|
+
connect_timeout=CONNECT_TIMEOUT_SECONDS,
|
|
42
|
+
autocommit=False,
|
|
43
|
+
ssl=ssl_ctx,
|
|
44
|
+
auth_plugin_map=auth_plugin_map(),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error_code(exc: pymysql.err.MySQLError) -> int | None:
|
|
49
|
+
"""The server error number, when the driver reports one."""
|
|
50
|
+
code = exc.args[0] if exc.args else None
|
|
51
|
+
return code if isinstance(code, int) else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify(exc: BaseException) -> ConnectionState:
|
|
55
|
+
"""Map a connection failure onto a :class:`ConnectionState`."""
|
|
56
|
+
from .tls import is_tls_failure
|
|
57
|
+
|
|
58
|
+
if is_tls_failure(exc):
|
|
59
|
+
return ConnectionState.TLS_FAILED
|
|
60
|
+
if isinstance(exc, pymysql.err.MySQLError):
|
|
61
|
+
code = error_code(exc)
|
|
62
|
+
if code == ER_ACCESS_DENIED:
|
|
63
|
+
return ConnectionState.AUTHENTICATION_FAILED
|
|
64
|
+
if code == ER_DBACCESS_DENIED:
|
|
65
|
+
return ConnectionState.ACCESS_DENIED
|
|
66
|
+
if code == ER_BAD_DB:
|
|
67
|
+
return ConnectionState.DATABASE_ABSENT
|
|
68
|
+
return ConnectionState.UNREACHABLE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def literal(fragment: str) -> str:
|
|
72
|
+
"""Protect an SQL fragment that is pasted into a statement executed WITH arguments.
|
|
73
|
+
|
|
74
|
+
PyMySQL substitutes arguments with Python ``%``-formatting whenever any are
|
|
75
|
+
passed, so a ``%`` inside a quoted identifier (MySQL allows it in database
|
|
76
|
+
names) would be read as a format directive. Statements executed without
|
|
77
|
+
arguments must not use this: there the ``%`` is sent as written.
|
|
78
|
+
"""
|
|
79
|
+
return fragment.replace("%", "%%")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The package version, read back from installed distribution metadata.
|
|
2
|
+
|
|
3
|
+
This module holds no version literal, and neither does ``pyproject.toml``. The
|
|
4
|
+
git tag is the single source: ``hatch-vcs`` derives the distribution version from
|
|
5
|
+
it at build time (AD-30), and this reads that value back. So "keep the tag and
|
|
6
|
+
the package version in sync" is not a rule that can be broken -- there is only
|
|
7
|
+
one value, and the tag decides it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
#: Distribution name, which is what carries the version metadata.
|
|
15
|
+
_DISTRIBUTION = "l3io-wordpress-database"
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = version(_DISTRIBUTION)
|
|
19
|
+
except PackageNotFoundError: # pragma: no cover - a source tree never installed
|
|
20
|
+
# Deliberately sorts below every real release, so an uninstalled tree can
|
|
21
|
+
# never be mistaken for a newer one.
|
|
22
|
+
__version__ = "0.0.0+unknown"
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""WordPress ``$wp_db_version`` to release number.
|
|
2
|
+
|
|
3
|
+
GENERATED FILE -- do not edit by hand.
|
|
4
|
+
Regenerate with: uv run python tools/generate_version_map.py
|
|
5
|
+
|
|
6
|
+
Derived from 328 of 328 released versions at or above
|
|
7
|
+
WordPress 5.0, listed by api.wordpress.org/core/stable-check/1.0/,
|
|
8
|
+
reading $wp_db_version from wp-includes/version.php at each release tag.
|
|
9
|
+
|
|
10
|
+
MIN_RELEASE = '5.0'
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
#: db_version -> the earliest WordPress release that reported it.
|
|
16
|
+
DB_VERSION_TO_RELEASE: dict[int, str] = {
|
|
17
|
+
43764: "5.0",
|
|
18
|
+
44719: "5.1",
|
|
19
|
+
45805: "5.3",
|
|
20
|
+
47018: "5.4",
|
|
21
|
+
48748: "5.5",
|
|
22
|
+
49752: "5.6",
|
|
23
|
+
51917: "5.9",
|
|
24
|
+
53496: "6.0.1",
|
|
25
|
+
55853: "6.3",
|
|
26
|
+
56657: "6.4",
|
|
27
|
+
57155: "6.5",
|
|
28
|
+
58975: "6.7",
|
|
29
|
+
60421: "6.8.2",
|
|
30
|
+
60717: "6.9",
|
|
31
|
+
61833: "7.0",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#: Oldest release included, as a support decision.
|
|
35
|
+
MIN_RELEASE = "5.0"
|
l3io/wp/database/auth.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Authentication-plugin policy for every PyMySQL connection.
|
|
2
|
+
|
|
3
|
+
A server chooses the auth method. PyMySQL will honour ``mysql_clear_password``
|
|
4
|
+
(password bytes on the wire), ``mysql_old_password``, and ``dialog``. An
|
|
5
|
+
attacker who presents a valid TLS certificate and asks for cleartext auth can
|
|
6
|
+
collect the administrator password. Connections opened by this package therefore
|
|
7
|
+
refuse those plugins and allow only the hashed methods below (plus MariaDB's
|
|
8
|
+
``client_ed25519``, which PyMySQL already implements).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .errors import AuthPluginRefusedError
|
|
17
|
+
|
|
18
|
+
#: Plugins this package will complete a handshake for.
|
|
19
|
+
ALLOWED_AUTH_PLUGINS: frozenset[str] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"mysql_native_password",
|
|
22
|
+
"caching_sha2_password",
|
|
23
|
+
"sha256_password",
|
|
24
|
+
"client_ed25519", # MariaDB; built into PyMySQL
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: Plugins PyMySQL would otherwise honour that send or prompt for a password
|
|
29
|
+
#: in a form we refuse. Mapped to a refusing handler via :func:`auth_plugin_map`.
|
|
30
|
+
REFUSED_AUTH_PLUGINS: frozenset[str] = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"mysql_clear_password",
|
|
33
|
+
"mysql_old_password",
|
|
34
|
+
"dialog",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _refuse_class(plugin: str) -> Callable[[Any], Any]:
|
|
40
|
+
"""A PyMySQL ``auth_plugin_map`` handler that refuses ``plugin``."""
|
|
41
|
+
|
|
42
|
+
class _Refuse:
|
|
43
|
+
def __init__(self, connection: object) -> None:
|
|
44
|
+
self.connection = connection
|
|
45
|
+
|
|
46
|
+
def authenticate(self, packet: object) -> None:
|
|
47
|
+
del packet
|
|
48
|
+
msg = f"authentication plugin {plugin!r} is not allowed"
|
|
49
|
+
raise AuthPluginRefusedError(plugin, msg)
|
|
50
|
+
|
|
51
|
+
def prompt(self, echo: bool, prompt: object) -> str:
|
|
52
|
+
# dialog falls back to prompt() if authenticate is missing; keep both.
|
|
53
|
+
del echo, prompt
|
|
54
|
+
msg = f"authentication plugin {plugin!r} is not allowed"
|
|
55
|
+
raise AuthPluginRefusedError(plugin, msg)
|
|
56
|
+
|
|
57
|
+
_Refuse.__name__ = f"Refuse_{plugin}"
|
|
58
|
+
_Refuse.__qualname__ = f"Refuse_{plugin}"
|
|
59
|
+
return _Refuse
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def auth_plugin_map() -> dict[str, Callable[[Any], Any]]:
|
|
63
|
+
"""Handlers that raise :class:`AuthPluginRefusedError` for refused plugins."""
|
|
64
|
+
return {name: _refuse_class(name) for name in sorted(REFUSED_AUTH_PLUGINS)}
|