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
|
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
|
+
}
|
l3io/wp/database/tls.py
ADDED
|
@@ -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}"'
|