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.
File without changes
@@ -0,0 +1,139 @@
1
+ """Which server this is, and what the session's own account may do."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from typing import Any
9
+
10
+ from opentelemetry import trace
11
+
12
+ #: Floors: MariaDB 10.1 rejects ALTER USER, which provisioning needs.
13
+ _FLOOR = {"mysql": (8, 0, 0), "mariadb": (10, 2, 0)}
14
+
15
+ #: Upstream end of life, per (family, major, minor) series. Data, not logic:
16
+ #: extend it as series retire.
17
+ _END_OF_LIFE: frozenset[tuple[str, int, int]] = frozenset(
18
+ {
19
+ ("mysql", 5, 7),
20
+ ("mysql", 8, 0),
21
+ # Innovation releases are supported only until the next one ships.
22
+ ("mysql", 8, 1),
23
+ ("mysql", 8, 2),
24
+ ("mysql", 8, 3),
25
+ *(("mysql", 9, minor) for minor in range(7)),
26
+ ("mariadb", 10, 1),
27
+ ("mariadb", 10, 2),
28
+ ("mariadb", 10, 3),
29
+ ("mariadb", 10, 4),
30
+ ("mariadb", 10, 5),
31
+ ("mariadb", 10, 6), # LTS, ended July 2026
32
+ # Short-term releases between the 10.11 and 11.4 LTS lines.
33
+ ("mariadb", 11, 0),
34
+ ("mariadb", 11, 1),
35
+ ("mariadb", 11, 2),
36
+ ("mariadb", 11, 3),
37
+ }
38
+ )
39
+
40
+ _VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
41
+
42
+
43
+ class ServerFamily(StrEnum):
44
+ """The two server families this package supports."""
45
+
46
+ MYSQL = "mysql"
47
+ MARIADB = "mariadb"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ServerInfo:
52
+ """A server's family and version."""
53
+
54
+ family: ServerFamily
55
+ version: tuple[int, int, int]
56
+ version_text: str
57
+
58
+ @property
59
+ def is_supported(self) -> bool:
60
+ return self.version >= _FLOOR[self.family.value]
61
+
62
+ @property
63
+ def is_end_of_life(self) -> bool:
64
+ major, minor, _ = self.version
65
+ return (self.family.value, major, minor) in _END_OF_LIFE or not self.is_supported
66
+
67
+
68
+ def parse_version(text: str) -> ServerInfo:
69
+ """Interpret ``SELECT VERSION()``."""
70
+ match = _VERSION.match(text)
71
+ if match is None:
72
+ msg = f"unrecognised server version {text!r}"
73
+ raise ValueError(msg)
74
+ major, minor, patch = (int(g) for g in match.groups())
75
+ family = ServerFamily.MARIADB if "mariadb" in text.lower() else ServerFamily.MYSQL
76
+ return ServerInfo(family, (major, minor, patch), text)
77
+
78
+
79
+ def detect_server(cursor: Any) -> ServerInfo:
80
+ """Ask the server what it is."""
81
+ cursor.execute("SELECT VERSION()")
82
+ info = parse_version(str(cursor.fetchone()[0]))
83
+ # Every detection happens inside an operation's span (spec §8).
84
+ trace.get_current_span().set_attribute("db.system", info.family.value)
85
+ return info
86
+
87
+
88
+ def grantee(user: str, host: str) -> str:
89
+ """The ``GRANTEE`` form ``information_schema`` uses."""
90
+ return f"'{user}'@'{host}'"
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class Privileges:
95
+ """What the session's own account holds on one database."""
96
+
97
+ names: frozenset[str]
98
+ grantable: bool
99
+ unverified: bool
100
+ #: Only the privileges held on ``*.*``; ``names`` also holds database-level ones.
101
+ global_names: frozenset[str] = frozenset()
102
+
103
+ def has(self, name: str) -> bool:
104
+ return self.grantable if name == "GRANT OPTION" else name in self.names
105
+
106
+ def has_global(self, name: str) -> bool:
107
+ return name in self.global_names
108
+
109
+
110
+ def account_privileges(cursor: Any, database: str) -> Privileges:
111
+ """Global and database-level privileges of the session's own account.
112
+
113
+ ``unverified`` is set when a role is active: ``information_schema`` lists
114
+ only directly granted privileges, so a role-held privilege would read as
115
+ missing.
116
+ """
117
+ cursor.execute("SELECT CURRENT_USER()")
118
+ user, _, host = str(cursor.fetchone()[0]).rpartition("@")
119
+ cursor.execute("SELECT CURRENT_ROLE()")
120
+ role = cursor.fetchone()[0]
121
+ unverified = role is not None and str(role).upper() != "NONE"
122
+
123
+ who = grantee(user, host)
124
+ cursor.execute(
125
+ "SELECT PRIVILEGE_TYPE, IS_GRANTABLE FROM information_schema.USER_PRIVILEGES "
126
+ "WHERE GRANTEE = %s",
127
+ (who,),
128
+ )
129
+ rows = list(cursor.fetchall())
130
+ global_names = frozenset(str(r[0]) for r in rows)
131
+ cursor.execute(
132
+ "SELECT PRIVILEGE_TYPE, IS_GRANTABLE FROM information_schema.SCHEMA_PRIVILEGES "
133
+ "WHERE GRANTEE = %s AND %s LIKE TABLE_SCHEMA",
134
+ (who, database),
135
+ )
136
+ rows += list(cursor.fetchall())
137
+ names = frozenset(str(r[0]) for r in rows)
138
+ grantable = any(str(r[1]).upper() == "YES" for r in rows)
139
+ return Privileges(names, grantable, unverified, global_names)
@@ -0,0 +1,82 @@
1
+ """Outcomes of inspecting a database's state.
2
+
3
+ "Does this database exist?" answered with a bare boolean, from a method that
4
+ catches every exception and returns ``False``, makes "absent", "wrong password"
5
+ and "host unreachable" indistinguishable -- and since the caller's next move is
6
+ to create objects using *administrative* credentials, collapsing those cases is
7
+ the dangerous part, not a cosmetic one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import StrEnum
13
+
14
+
15
+ class ConnectionState(StrEnum):
16
+ """Why an attempt to use a database succeeded or failed."""
17
+
18
+ READY = "ready"
19
+ """Connected, authenticated, and the database is usable."""
20
+
21
+ DATABASE_ABSENT = "database_absent"
22
+ """Authenticated, but the target database does not exist.
23
+
24
+ Only observable once the account itself exists: the server authenticates
25
+ before it resolves the database, so a site with neither reports
26
+ :attr:`AUTHENTICATION_FAILED` instead. Verified against MySQL 8.0.
27
+ """
28
+
29
+ ACCESS_DENIED = "access_denied"
30
+ """Authenticated, but this account may not use the target database."""
31
+
32
+ AUTHENTICATION_FAILED = "authentication_failed"
33
+ """The server was reached and rejected the credentials."""
34
+
35
+ UNREACHABLE = "unreachable"
36
+ """The server could not be reached."""
37
+
38
+ TLS_FAILED = "tls_failed"
39
+ """The server was contacted but TLS was required and failed."""
40
+
41
+ AUTH_PLUGIN_REFUSED = "auth_plugin_refused"
42
+ """The server asked for an authentication plugin this package refuses."""
43
+
44
+ CREDENTIALS_UNOBTAINABLE = "credentials_unobtainable"
45
+ """Credentials could not be obtained, so nothing was attempted."""
46
+
47
+ @property
48
+ def is_ready(self) -> bool:
49
+ """Whether the database is usable as configured."""
50
+ return self is ConnectionState.READY
51
+
52
+ @property
53
+ def needs_provisioning(self) -> bool:
54
+ """Whether provisioning with administrative credentials could fix this.
55
+
56
+ Authentication failure counts. Before first provisioning the WordPress
57
+ account does not exist yet, so inspecting as that account *must* fail --
58
+ treating that as a refusal to provision would reject the single most
59
+ common case this package exists for: a database created by
60
+ ``wp db create`` or a MySQL container's ``MYSQL_DATABASE``, with no user
61
+ yet. The administrator is the authority on what exists, not the failed
62
+ login of an account that is the whole point of the operation.
63
+ """
64
+ return self in {
65
+ ConnectionState.DATABASE_ABSENT,
66
+ ConnectionState.AUTHENTICATION_FAILED,
67
+ ConnectionState.ACCESS_DENIED,
68
+ }
69
+
70
+ @property
71
+ def blocks_provisioning(self) -> bool:
72
+ """Whether provisioning cannot proceed, whatever credentials are held.
73
+
74
+ An unreachable server or unobtainable credentials say nothing about
75
+ what exists, so nothing may be created on the strength of them.
76
+ """
77
+ return self in {
78
+ ConnectionState.UNREACHABLE,
79
+ ConnectionState.TLS_FAILED,
80
+ ConnectionState.AUTH_PLUGIN_REFUSED,
81
+ ConnectionState.CREDENTIALS_UNOBTAINABLE,
82
+ }
@@ -0,0 +1,211 @@
1
+ """TLS settings for database connections.
2
+
3
+ AD-3 keeps the site descriptor free of credentials; TLS is about *how* the
4
+ host is reached, so it lives on :class:`~l3io.wp.database.Connection` rather
5
+ than on a credential source. The same settings drive PyMySQL and the client
6
+ option-file pipe (ADR-0002).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ssl
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+
16
+ from .errors import InvalidArgumentsError
17
+ from .server import ServerFamily
18
+
19
+ #: Hosts treated as local by :func:`tls_required_unless_local`.
20
+ DEFAULT_LOCAL_HOSTS: frozenset[str] = frozenset(
21
+ {
22
+ "localhost",
23
+ "127.0.0.1",
24
+ "::1",
25
+ "0:0:0:0:0:0:0:1",
26
+ }
27
+ )
28
+
29
+
30
+ class TlsMode(StrEnum):
31
+ """How strongly TLS is required for a connection."""
32
+
33
+ OFF = "off"
34
+ """No TLS. Today's default; plaintext TCP."""
35
+
36
+ REQUIRED = "required"
37
+ """TLS required; server certificate is not verified."""
38
+
39
+ VERIFY_CA = "verify_ca"
40
+ """TLS required; certificate chain verified against a CA (hostname not checked)."""
41
+
42
+ VERIFY_IDENTITY = "verify_identity"
43
+ """TLS required; certificate chain and hostname verified."""
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class TlsSettings:
48
+ """TLS options for one :class:`~l3io.wp.database.Connection`."""
49
+
50
+ mode: TlsMode = TlsMode.OFF
51
+ ca_file: Path | None = None
52
+ cert_file: Path | None = None
53
+ key_file: Path | None = None
54
+
55
+ def __post_init__(self) -> None:
56
+ if self.mode is TlsMode.OFF:
57
+ if self.ca_file is not None or self.cert_file is not None or self.key_file is not None:
58
+ msg = "ca_file, cert_file and key_file require a TLS mode other than OFF"
59
+ raise InvalidArgumentsError(msg)
60
+ return
61
+ if (self.cert_file is None) ^ (self.key_file is None):
62
+ msg = "cert_file and key_file must be supplied together"
63
+ raise InvalidArgumentsError(msg)
64
+ if self.mode in (TlsMode.VERIFY_CA, TlsMode.VERIFY_IDENTITY) and self.ca_file is None:
65
+ # System store is allowed: ca_file None means the default context CAs.
66
+ return
67
+ for label, path in (
68
+ ("ca_file", self.ca_file),
69
+ ("cert_file", self.cert_file),
70
+ ("key_file", self.key_file),
71
+ ):
72
+ if path is not None and not isinstance(path, Path):
73
+ msg = f"{label} must be a Path, not {type(path).__name__}"
74
+ raise InvalidArgumentsError(msg)
75
+
76
+
77
+ def tls_required_unless_local(
78
+ host: str,
79
+ *,
80
+ local_hosts: frozenset[str] = DEFAULT_LOCAL_HOSTS,
81
+ ca_file: Path | None = None,
82
+ cert_file: Path | None = None,
83
+ key_file: Path | None = None,
84
+ ) -> TlsSettings:
85
+ """Require verified TLS for non-local hosts; leave local hosts plaintext.
86
+
87
+ ``localhost``, ``127.0.0.1`` and ``::1`` (and any extra names in
88
+ ``local_hosts``) get :attr:`TlsMode.OFF`. Everything else gets
89
+ :attr:`TlsMode.VERIFY_IDENTITY` against ``ca_file`` or the system store.
90
+ """
91
+ if not host:
92
+ msg = "host must not be empty"
93
+ raise InvalidArgumentsError(msg)
94
+ if host.lower() in {h.lower() for h in local_hosts}:
95
+ return TlsSettings(mode=TlsMode.OFF)
96
+ return TlsSettings(
97
+ mode=TlsMode.VERIFY_IDENTITY,
98
+ ca_file=ca_file,
99
+ cert_file=cert_file,
100
+ key_file=key_file,
101
+ )
102
+
103
+
104
+ def pymysql_ssl(settings: TlsSettings) -> ssl.SSLContext | None:
105
+ """An :class:`ssl.SSLContext` for PyMySQL, or ``None`` when TLS is off."""
106
+ if settings.mode is TlsMode.OFF:
107
+ return None
108
+ if settings.mode is TlsMode.REQUIRED:
109
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
110
+ ctx.check_hostname = False
111
+ ctx.verify_mode = ssl.CERT_NONE
112
+ else:
113
+ ctx = ssl.create_default_context(cafile=_path_str(settings.ca_file))
114
+ if settings.mode is TlsMode.VERIFY_CA:
115
+ ctx.check_hostname = False
116
+ ctx.verify_mode = ssl.CERT_REQUIRED
117
+ else:
118
+ ctx.check_hostname = True
119
+ ctx.verify_mode = ssl.CERT_REQUIRED
120
+ if settings.cert_file is not None and settings.key_file is not None:
121
+ ctx.load_cert_chain(str(settings.cert_file), str(settings.key_file))
122
+ return ctx
123
+
124
+
125
+ def option_file_lines(settings: TlsSettings, family: ServerFamily) -> tuple[str, ...]:
126
+ """``[client]`` TLS lines for one client family.
127
+
128
+ MySQL 8.4 rejects ``ssl=1`` (``ssl-mode`` only). MariaDB rejects
129
+ ``ssl-mode`` (``ssl`` plus ``ssl-verify-server-cert`` for verify modes).
130
+ Never use the ``loose-`` prefix: an unknown security option must fail
131
+ loudly, not skip verification.
132
+ """
133
+ if settings.mode is TlsMode.OFF:
134
+ return ()
135
+ lines: list[str] = []
136
+ if family is ServerFamily.MYSQL:
137
+ lines.append(f"ssl-mode={_client_ssl_mode(settings.mode)}")
138
+ else:
139
+ lines.append("ssl=1")
140
+ if settings.mode in (TlsMode.VERIFY_CA, TlsMode.VERIFY_IDENTITY):
141
+ lines.append("ssl-verify-server-cert=1")
142
+ if settings.ca_file is not None:
143
+ lines.append(f"ssl-ca={_quote_path(settings.ca_file)}")
144
+ if settings.cert_file is not None:
145
+ lines.append(f"ssl-cert={_quote_path(settings.cert_file)}")
146
+ if settings.key_file is not None:
147
+ lines.append(f"ssl-key={_quote_path(settings.key_file)}")
148
+ return tuple(lines)
149
+
150
+
151
+ def is_tls_failure(exc: BaseException) -> bool:
152
+ """Whether a connect failure is a TLS/SSL problem rather than plain unreachability."""
153
+ if isinstance(exc, ssl.SSLError):
154
+ return True
155
+ text = str(exc).lower()
156
+ markers = (
157
+ "ssl",
158
+ "tls",
159
+ "certificate",
160
+ "cert verify",
161
+ "certificate verify failed",
162
+ "wrong version number",
163
+ "handshake",
164
+ )
165
+ return any(m in text for m in markers)
166
+
167
+
168
+ def is_verification_failure(exc: BaseException) -> bool:
169
+ """Whether a TLS failure is specifically certificate verification."""
170
+ if isinstance(exc, ssl.SSLCertVerificationError):
171
+ return True
172
+ text = str(exc).lower()
173
+ markers = (
174
+ "certificate verify failed",
175
+ "certificate_verify_failed",
176
+ "hostname mismatch",
177
+ "certificate has expired",
178
+ "self-signed certificate",
179
+ "unable to get local issuer",
180
+ )
181
+ return any(m in text for m in markers)
182
+
183
+
184
+ def _client_ssl_mode(mode: TlsMode) -> str:
185
+ return {
186
+ TlsMode.REQUIRED: "REQUIRED",
187
+ TlsMode.VERIFY_CA: "VERIFY_CA",
188
+ TlsMode.VERIFY_IDENTITY: "VERIFY_IDENTITY",
189
+ }[mode]
190
+
191
+
192
+ def _path_str(path: Path | None) -> str | None:
193
+ return None if path is None else str(path)
194
+
195
+
196
+ def _quote_path(path: Path) -> str:
197
+ """Quote a filesystem path for an option-file value (same escaping as credentials)."""
198
+ value = str(path)
199
+ if "\x00" in value:
200
+ msg = "a TLS path cannot contain a NUL character"
201
+ raise InvalidArgumentsError(msg)
202
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
203
+ try:
204
+ size = len(escaped.encode())
205
+ except UnicodeEncodeError as exc:
206
+ msg = "a TLS path is not valid UTF-8 text"
207
+ raise InvalidArgumentsError(msg) from exc
208
+ if size > 1024:
209
+ msg = "a TLS path is longer than 1024 bytes once escaped"
210
+ raise InvalidArgumentsError(msg)
211
+ return f'"{escaped}"'