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,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
|