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,220 @@
1
+ """One object for one site: every operation with the connection bound."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable, Sequence
7
+ from typing import IO
8
+
9
+ from . import inspection, provision, transfer
10
+ from .connection import Connection
11
+ from .credentials import CredentialSource
12
+ from .inspection import Survey
13
+ from .provision import EnsureResult, OperationResult
14
+ from .state import ConnectionState
15
+ from .transfer import DropTablesResult, DumpStream, TransferResult
16
+ from .verdict import Verdict
17
+ from .versions import SchemaVersion
18
+
19
+
20
+ class Database:
21
+ """Every operation against one :class:`Connection`.
22
+
23
+ The free functions are the primary API; this binds one connection so a
24
+ single object can serve one site.
25
+ """
26
+
27
+ __slots__ = ("_connection",)
28
+
29
+ def __init__(self, connection: Connection) -> None:
30
+ self._connection = connection
31
+
32
+ @property
33
+ def connection(self) -> Connection:
34
+ """The site this object operates on."""
35
+ return self._connection
36
+
37
+ def survey(
38
+ self,
39
+ admin: CredentialSource,
40
+ *,
41
+ wordpress_user: str | None = None,
42
+ user_host: str = "%",
43
+ table_prefix: str = "wp_",
44
+ ) -> Survey:
45
+ return inspection.survey(
46
+ self._connection,
47
+ admin,
48
+ wordpress_user=wordpress_user,
49
+ user_host=user_host,
50
+ table_prefix=table_prefix,
51
+ )
52
+
53
+ def inspect(self, credentials: CredentialSource) -> ConnectionState:
54
+ return inspection.inspect(self._connection, credentials)
55
+
56
+ def schema_version(
57
+ self, credentials: CredentialSource, table_prefix: str = "wp_"
58
+ ) -> SchemaVersion | None:
59
+ return inspection.schema_version(self._connection, credentials, table_prefix)
60
+
61
+ def check_ensure(
62
+ self, admin: CredentialSource, wordpress: CredentialSource, *, user_host: str = "%"
63
+ ) -> Verdict:
64
+ return provision.check_ensure(self._connection, admin, wordpress, user_host=user_host)
65
+
66
+ def ensure(
67
+ self,
68
+ admin: CredentialSource,
69
+ wordpress: CredentialSource,
70
+ *,
71
+ user_host: str = "%",
72
+ charset: str = "utf8mb4",
73
+ collation: str = "utf8mb4_unicode_ci",
74
+ ) -> EnsureResult:
75
+ return provision.ensure(
76
+ self._connection,
77
+ admin,
78
+ wordpress,
79
+ user_host=user_host,
80
+ charset=charset,
81
+ collation=collation,
82
+ )
83
+
84
+ def check_rotate_password(
85
+ self, admin: CredentialSource, wordpress: CredentialSource, *, user_host: str = "%"
86
+ ) -> Verdict:
87
+ return provision.check_rotate_password(
88
+ self._connection, admin, wordpress, user_host=user_host
89
+ )
90
+
91
+ def rotate_password(
92
+ self, admin: CredentialSource, wordpress: CredentialSource, *, user_host: str = "%"
93
+ ) -> OperationResult:
94
+ return provision.rotate_password(self._connection, admin, wordpress, user_host=user_host)
95
+
96
+ def check_revoke(
97
+ self, admin: CredentialSource, user: str, *, user_host: str = "%", confirm: str
98
+ ) -> Verdict:
99
+ return provision.check_revoke(
100
+ self._connection, admin, user, user_host=user_host, confirm=confirm
101
+ )
102
+
103
+ def revoke(
104
+ self, admin: CredentialSource, user: str, *, user_host: str = "%", confirm: str
105
+ ) -> OperationResult:
106
+ return provision.revoke(self._connection, admin, user, user_host=user_host, confirm=confirm)
107
+
108
+ def check_drop_user(
109
+ self, admin: CredentialSource, user: str, *, user_host: str = "%", confirm: str
110
+ ) -> Verdict:
111
+ return provision.check_drop_user(
112
+ self._connection, admin, user, user_host=user_host, confirm=confirm
113
+ )
114
+
115
+ def drop_user(
116
+ self, admin: CredentialSource, user: str, *, user_host: str = "%", confirm: str
117
+ ) -> OperationResult:
118
+ return provision.drop_user(
119
+ self._connection, admin, user, user_host=user_host, confirm=confirm
120
+ )
121
+
122
+ def check_drop_database(self, admin: CredentialSource, *, confirm: str) -> Verdict:
123
+ return provision.check_drop_database(self._connection, admin, confirm=confirm)
124
+
125
+ def drop_database(self, admin: CredentialSource, *, confirm: str) -> OperationResult:
126
+ return provision.drop_database(self._connection, admin, confirm=confirm)
127
+
128
+ def check_dump(self, credentials: CredentialSource, *, options: Sequence[str] = ()) -> Verdict:
129
+ return transfer.check_dump(self._connection, credentials, options=options)
130
+
131
+ def check_drop_tables(self, credentials: CredentialSource, *, prefix: str) -> DropTablesResult:
132
+ return transfer.check_drop_tables(self._connection, credentials, prefix=prefix)
133
+
134
+ def drop_tables(self, credentials: CredentialSource, *, prefix: str) -> DropTablesResult:
135
+ return transfer.drop_tables(self._connection, credentials, prefix=prefix)
136
+
137
+ def iter_dump(
138
+ self,
139
+ credentials: CredentialSource,
140
+ *,
141
+ recipients: Sequence[str] | None = None,
142
+ passphrase: str | None = None,
143
+ options: Sequence[str] = (),
144
+ ) -> DumpStream:
145
+ return transfer.iter_dump(
146
+ self._connection,
147
+ credentials,
148
+ recipients=recipients,
149
+ passphrase=passphrase,
150
+ options=options,
151
+ )
152
+
153
+ def dump(
154
+ self,
155
+ credentials: CredentialSource,
156
+ to: IO[bytes] | str | os.PathLike[str],
157
+ *,
158
+ recipients: Sequence[str] | None = None,
159
+ passphrase: str | None = None,
160
+ options: Sequence[str] = (),
161
+ ) -> TransferResult:
162
+ return transfer.dump(
163
+ self._connection,
164
+ credentials,
165
+ to,
166
+ recipients=recipients,
167
+ passphrase=passphrase,
168
+ options=options,
169
+ )
170
+
171
+ def check_load(
172
+ self,
173
+ credentials: CredentialSource,
174
+ *,
175
+ source: str | os.PathLike[str] | None = None,
176
+ replace: str | None = None,
177
+ prune_prefix: str | None = None,
178
+ identities: Sequence[str] | None = None,
179
+ passphrase: str | None = None,
180
+ options: Sequence[str] = (),
181
+ allow_unsandboxed: bool = False,
182
+ ) -> Verdict:
183
+ return transfer.check_load(
184
+ self._connection,
185
+ credentials,
186
+ source=source,
187
+ replace=replace,
188
+ prune_prefix=prune_prefix,
189
+ identities=identities,
190
+ passphrase=passphrase,
191
+ options=options,
192
+ allow_unsandboxed=allow_unsandboxed,
193
+ )
194
+
195
+ def load(
196
+ self,
197
+ credentials: CredentialSource,
198
+ source: IO[bytes] | Iterable[bytes] | str | os.PathLike[str],
199
+ *,
200
+ replace: str | None = None,
201
+ prune_prefix: str | None = None,
202
+ identities: Sequence[str] | None = None,
203
+ passphrase: str | None = None,
204
+ options: Sequence[str] = (),
205
+ allow_unsandboxed: bool = False,
206
+ ) -> TransferResult:
207
+ return transfer.load(
208
+ self._connection,
209
+ credentials,
210
+ source,
211
+ replace=replace,
212
+ prune_prefix=prune_prefix,
213
+ identities=identities,
214
+ passphrase=passphrase,
215
+ options=options,
216
+ allow_unsandboxed=allow_unsandboxed,
217
+ )
218
+
219
+ def __repr__(self) -> str:
220
+ return f"Database({self._connection!r})"
@@ -0,0 +1,95 @@
1
+ """Validating and quoting MySQL identifiers.
2
+
3
+ A database-level ``GRANT`` matches its database name as a ``LIKE`` pattern, so
4
+ ``_`` is a single-character wildcard. Granting on ``wp_site`` therefore also
5
+ grants on ``wpXsite``:
6
+
7
+ GRANT ALL PRIVILEGES ON `wp_site`.* TO 'victim'@'%';
8
+ -- victim can then SHOW and CREATE TABLE in wpXsite
9
+
10
+ Backticks do not suppress this; only escaping the wildcard does. Essentially
11
+ every WordPress database is named with an underscore, so an unescaped
12
+ ``GRANT ALL PRIVILEGES ON {n}.*`` over-grants every user it creates.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ from .errors import InvalidArgumentsError, InvalidDatabaseNameError, InvalidIdentifierError
20
+
21
+ #: MySQL caps database names at 64 characters. MariaDB allows longer user names
22
+ #: than MySQL's 32, so the stricter MySQL limit is applied to both.
23
+ MAX_DATABASE_NAME = 64
24
+ MAX_USER_NAME = 32
25
+
26
+ _FORBIDDEN = {"\x00", "\n", "\r", "`", "/", "\\", "."}
27
+ #: The grant lookup compares against the unescaped ``'user'@'host'`` form.
28
+ _FORBIDDEN_IN_USER_NAME = frozenset("'")
29
+
30
+
31
+ def _check(kind: str, value: str, limit: int, forbidden: frozenset[str] = frozenset()) -> None:
32
+ if not isinstance(value, str) or not value:
33
+ raise InvalidIdentifierError(kind, str(value), "must be a non-empty string")
34
+ if len(value) > limit:
35
+ raise InvalidIdentifierError(kind, value, f"longer than {limit} characters")
36
+ if value != value.strip():
37
+ raise InvalidIdentifierError(kind, value, "has leading or trailing whitespace")
38
+ found = (_FORBIDDEN | forbidden).intersection(value)
39
+ if found:
40
+ chars = ", ".join(repr(c) for c in sorted(found))
41
+ raise InvalidIdentifierError(kind, value, f"contains forbidden characters: {chars}")
42
+
43
+
44
+ def validate_database_name(name: str) -> str:
45
+ """Return ``name`` if it is usable as a database identifier, else raise."""
46
+ try:
47
+ _check("database name", name, MAX_DATABASE_NAME)
48
+ except InvalidIdentifierError as exc:
49
+ raise InvalidDatabaseNameError(str(name), exc.reason) from exc
50
+ return name
51
+
52
+
53
+ def validate_user_name(name: str) -> str:
54
+ """Return ``name`` if it is usable as a user identifier, else raise."""
55
+ _check("user name", name, MAX_USER_NAME, _FORBIDDEN_IN_USER_NAME)
56
+ return name
57
+
58
+
59
+ def quote_identifier(name: str) -> str:
60
+ """Backtick-quote an identifier, doubling any internal backticks."""
61
+ return "`" + name.replace("`", "``") + "`"
62
+
63
+
64
+ def grant_pattern(name: str) -> str:
65
+ """The database name as the ``LIKE`` pattern a GRANT stores, unquoted.
66
+
67
+ ``information_schema.SCHEMA_PRIVILEGES.TABLE_SCHEMA`` holds this form, so
68
+ comparing against it finds exactly the grant :func:`quote_grant_database`
69
+ wrote.
70
+ """
71
+ return name.replace("\\", "\\\\").replace("_", r"\_").replace("%", r"\%")
72
+
73
+
74
+ def quote_grant_database(name: str) -> str:
75
+ """Quote a database name for the ``ON <db>.*`` clause of a GRANT.
76
+
77
+ The clause is matched as a ``LIKE`` pattern, so ``_`` and ``%`` must be
78
+ escaped or the grant silently widens to every database matching it.
79
+ """
80
+ return quote_identifier(grant_pattern(name))
81
+
82
+
83
+ _CHARSET = re.compile(r"^[A-Za-z0-9_]+$")
84
+
85
+
86
+ def validate_charset(value: str, kind: str) -> str:
87
+ """Return a character set or collation name, or raise.
88
+
89
+ These cannot be parameterised, so they are restricted to the characters
90
+ every real name uses.
91
+ """
92
+ if not isinstance(value, str) or not _CHARSET.match(value):
93
+ msg = f"{kind} {value!r} is not a valid name"
94
+ raise InvalidArgumentsError(msg)
95
+ return value
@@ -0,0 +1,254 @@
1
+ """Read-only questions about a database, answered by the right account.
2
+
3
+ :func:`survey` runs as the administrator and is the authority on what exists
4
+ (AD-29). :func:`inspect` is the WordPress account's own login probe: it
5
+ describes the account, never whether the database exists.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import pymysql
14
+
15
+ from ._observe import operation
16
+ from ._sql import (
17
+ ER_DBACCESS_DENIED,
18
+ ER_TABLEACCESS_DENIED,
19
+ classify,
20
+ error_code,
21
+ literal,
22
+ open_connection,
23
+ )
24
+ from .connection import Connection
25
+ from .credentials import CredentialSource, ResolvedCredentials
26
+ from .errors import (
27
+ AccessDeniedError,
28
+ AuthenticationFailedError,
29
+ ConfigurationError,
30
+ CredentialsAbsentError,
31
+ CredentialsUnobtainableError,
32
+ DatabaseUnreachableError,
33
+ MissingDependencyError,
34
+ )
35
+ from .identifiers import quote_identifier, validate_user_name
36
+ from .server import ServerInfo, detect_server, grantee
37
+ from .state import ConnectionState
38
+ from .versions import SchemaVersion
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Survey:
43
+ """What the administrator can see about one database.
44
+
45
+ ``table_count`` is every row in ``information_schema.TABLES`` for the
46
+ schema, so base tables and views both count. Routines and events are
47
+ separate: they are not tables, and a database that holds only them still
48
+ reports ``table_count == 0``.
49
+ """
50
+
51
+ server: ServerInfo
52
+ database_exists: bool
53
+ table_count: int
54
+ size_bytes: int
55
+ is_wordpress: bool
56
+ schema_version: SchemaVersion | None
57
+ user_exists: bool | None
58
+ user_privileges: frozenset[str] | None
59
+ routine_count: int = 0
60
+ event_count: int = 0
61
+
62
+
63
+ def _resolve(source: CredentialSource) -> ResolvedCredentials:
64
+ return source.resolve()
65
+
66
+
67
+ def _connect_or_raise(
68
+ connection: Connection, credentials: ResolvedCredentials, database: str | None
69
+ ) -> Any:
70
+ """Open a session, or raise a package error saying why it could not be."""
71
+ from .errors import AuthPluginRefusedError, TlsHandshakeError, TlsVerificationError
72
+ from .tls import is_tls_failure, is_verification_failure
73
+
74
+ try:
75
+ return open_connection(connection, credentials, database=database)
76
+ except AuthPluginRefusedError:
77
+ raise
78
+ except Exception as exc: # noqa: BLE001 - SSLError and driver errors both map here
79
+ if (
80
+ isinstance(exc, pymysql.err.MySQLError)
81
+ and classify(exc) is ConnectionState.AUTHENTICATION_FAILED
82
+ ):
83
+ msg = f"the account {credentials.username!r} was rejected"
84
+ raise AuthenticationFailedError(msg) from exc
85
+ if is_verification_failure(exc):
86
+ msg = f"{connection.host}:{connection.port} presented a certificate that did not verify"
87
+ raise TlsVerificationError(msg) from exc
88
+ if is_tls_failure(exc) or classify(exc) is ConnectionState.TLS_FAILED:
89
+ msg = (
90
+ f"{connection.host}:{connection.port} did not complete a TLS "
91
+ "handshake (TLS was required)"
92
+ )
93
+ raise TlsHandshakeError(msg) from exc
94
+ msg = f"{connection.host}:{connection.port} could not be reached"
95
+ raise DatabaseUnreachableError(msg) from exc
96
+
97
+
98
+ def _admin_session(connection: Connection, admin: ResolvedCredentials) -> Any:
99
+ """An admin connection with no default database, or a package error."""
100
+ return _connect_or_raise(connection, admin, None)
101
+
102
+
103
+ def _as_package_error(exc: pymysql.err.MySQLError) -> Exception:
104
+ """Translate a server error raised mid-session into a package-owned one."""
105
+ if error_code(exc) in (ER_TABLEACCESS_DENIED, ER_DBACCESS_DENIED):
106
+ return AccessDeniedError(f"the administrator lacks a privilege the survey needs: {exc}")
107
+ return DatabaseUnreachableError(f"the server failed during the survey: {exc}")
108
+
109
+
110
+ def _read_schema_version(cursor: Any, database: str, table_prefix: str) -> SchemaVersion | None:
111
+ table = f"{quote_identifier(database)}.{quote_identifier(table_prefix + 'options')}"
112
+ cursor.execute(
113
+ f"SELECT option_value FROM {literal(table)} WHERE option_name = %s", # noqa: S608 - quoted identifiers
114
+ ("db_version",),
115
+ )
116
+ row = cursor.fetchone()
117
+ if row is None or row[0] is None:
118
+ return None
119
+ try:
120
+ return SchemaVersion(int(row[0]))
121
+ except (TypeError, ValueError) as exc:
122
+ msg = f"db_version {row[0]!r} in {table} is not an integer"
123
+ raise AccessDeniedError(msg) from exc
124
+
125
+
126
+ def survey(
127
+ connection: Connection,
128
+ admin: CredentialSource,
129
+ *,
130
+ wordpress_user: str | None = None,
131
+ user_host: str = "%",
132
+ table_prefix: str = "wp_",
133
+ ) -> Survey:
134
+ """Everything the administrator can see about this database and account."""
135
+ with operation("survey", connection) as fields:
136
+ session = _admin_session(connection, _resolve(admin))
137
+ database = connection.database
138
+ try:
139
+ with session, session.cursor() as cur:
140
+ server = detect_server(cur)
141
+ cur.execute(
142
+ "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = %s",
143
+ (database,),
144
+ )
145
+ exists = int(cur.fetchone()[0]) > 0
146
+ cur.execute(
147
+ "SELECT COUNT(*), COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0) "
148
+ "FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s",
149
+ (database,),
150
+ )
151
+ table_count, size_bytes = (int(v) for v in cur.fetchone())
152
+ cur.execute(
153
+ "SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = %s",
154
+ (database,),
155
+ )
156
+ routine_count = int(cur.fetchone()[0])
157
+ cur.execute(
158
+ "SELECT COUNT(*) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = %s",
159
+ (database,),
160
+ )
161
+ event_count = int(cur.fetchone()[0])
162
+ cur.execute(
163
+ "SELECT COUNT(*) FROM information_schema.TABLES "
164
+ "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
165
+ (database, f"{table_prefix}options"),
166
+ )
167
+ is_wordpress = int(cur.fetchone()[0]) > 0
168
+ version = (
169
+ _read_schema_version(cur, database, table_prefix) if is_wordpress else None
170
+ )
171
+
172
+ user_exists: bool | None = None
173
+ user_privileges: frozenset[str] | None = None
174
+ if wordpress_user is not None:
175
+ validate_user_name(wordpress_user)
176
+ try:
177
+ cur.execute(
178
+ "SELECT COUNT(*) FROM mysql.user WHERE User = %s AND Host = %s",
179
+ (wordpress_user, user_host),
180
+ )
181
+ user_exists = int(cur.fetchone()[0]) > 0
182
+ except pymysql.err.MySQLError as exc:
183
+ if error_code(exc) != ER_TABLEACCESS_DENIED:
184
+ raise
185
+ cur.execute(
186
+ "SELECT PRIVILEGE_TYPE FROM information_schema.SCHEMA_PRIVILEGES "
187
+ "WHERE GRANTEE = %s AND %s LIKE TABLE_SCHEMA",
188
+ (grantee(wordpress_user, user_host), database),
189
+ )
190
+ user_privileges = frozenset(str(r[0]) for r in cur.fetchall())
191
+ except pymysql.err.MySQLError as exc:
192
+ raise _as_package_error(exc) from exc
193
+ fields.update(
194
+ database_exists=exists,
195
+ table_count=table_count,
196
+ routine_count=routine_count,
197
+ event_count=event_count,
198
+ is_wordpress=is_wordpress,
199
+ )
200
+ return Survey(
201
+ server,
202
+ exists,
203
+ table_count,
204
+ size_bytes,
205
+ is_wordpress,
206
+ version,
207
+ user_exists,
208
+ user_privileges,
209
+ routine_count,
210
+ event_count,
211
+ )
212
+
213
+
214
+ def inspect(connection: Connection, credentials: CredentialSource) -> ConnectionState:
215
+ """Whether this account can use this database, and if not, why."""
216
+ with operation("inspect", connection) as fields:
217
+ try:
218
+ resolved = _resolve(credentials)
219
+ except (
220
+ CredentialsUnobtainableError,
221
+ CredentialsAbsentError,
222
+ ConfigurationError,
223
+ MissingDependencyError,
224
+ ):
225
+ fields["state"] = str(ConnectionState.CREDENTIALS_UNOBTAINABLE)
226
+ return ConnectionState.CREDENTIALS_UNOBTAINABLE
227
+ from .errors import AuthPluginRefusedError
228
+
229
+ try:
230
+ session = open_connection(connection, resolved, database=connection.database)
231
+ except AuthPluginRefusedError:
232
+ state = ConnectionState.AUTH_PLUGIN_REFUSED
233
+ except Exception as exc: # noqa: BLE001 - SSLError and driver errors both map here
234
+ state = classify(exc)
235
+ else:
236
+ session.close()
237
+ state = ConnectionState.READY
238
+ fields["state"] = str(state)
239
+ return state
240
+
241
+
242
+ def schema_version(
243
+ connection: Connection, credentials: CredentialSource, table_prefix: str = "wp_"
244
+ ) -> SchemaVersion | None:
245
+ """The WordPress schema revision recorded in the options table."""
246
+ with operation("schema_version", connection):
247
+ resolved = _resolve(credentials)
248
+ session = _connect_or_raise(connection, resolved, connection.database)
249
+ try:
250
+ with session, session.cursor() as cur:
251
+ return _read_schema_version(cur, connection.database, table_prefix)
252
+ except pymysql.err.MySQLError as exc:
253
+ msg = f"could not read {table_prefix}options; is the table prefix correct?"
254
+ raise AccessDeniedError(msg) from exc