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.
@@ -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)
@@ -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"
@@ -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)}