l3io-wordpress-backup 1.0.0__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/backup/__init__.py +22 -0
- l3io/wp/backup/_version.py +31 -0
- l3io/wp/backup/adapters/__init__.py +9 -0
- l3io/wp/backup/adapters/clock.py +22 -0
- l3io/wp/backup/adapters/config_writer.py +702 -0
- l3io/wp/backup/adapters/credential_providers.py +236 -0
- l3io/wp/backup/adapters/crypto.py +237 -0
- l3io/wp/backup/adapters/database.py +1555 -0
- l3io/wp/backup/adapters/database_load.py +1263 -0
- l3io/wp/backup/adapters/exclusions.py +83 -0
- l3io/wp/backup/adapters/journal.py +507 -0
- l3io/wp/backup/adapters/lock.py +372 -0
- l3io/wp/backup/adapters/noop.py +666 -0
- l3io/wp/backup/adapters/phase.py +218 -0
- l3io/wp/backup/adapters/preflight.py +869 -0
- l3io/wp/backup/adapters/redaction.py +237 -0
- l3io/wp/backup/adapters/restore_extension.py +341 -0
- l3io/wp/backup/adapters/restore_runner.py +1030 -0
- l3io/wp/backup/adapters/site_files.py +191 -0
- l3io/wp/backup/adapters/store.py +227 -0
- l3io/wp/backup/adapters/tar.py +1648 -0
- l3io/wp/backup/adapters/tracing.py +235 -0
- l3io/wp/backup/adapters/workspace.py +299 -0
- l3io/wp/backup/adapters/wpcli.py +584 -0
- l3io/wp/backup/adapters/wpcli_child.py +135 -0
- l3io/wp/backup/api/__init__.py +104 -0
- l3io/wp/backup/api/errors.py +344 -0
- l3io/wp/backup/api/operations.py +427 -0
- l3io/wp/backup/cli/__init__.py +228 -0
- l3io/wp/backup/cli/adapter_sets.py +255 -0
- l3io/wp/backup/cli/backup.py +1118 -0
- l3io/wp/backup/cli/context.py +382 -0
- l3io/wp/backup/cli/credentials.py +310 -0
- l3io/wp/backup/cli/doctor.py +171 -0
- l3io/wp/backup/cli/exit.py +171 -0
- l3io/wp/backup/cli/logging.py +147 -0
- l3io/wp/backup/cli/output.py +111 -0
- l3io/wp/backup/cli/parser.py +853 -0
- l3io/wp/backup/cli/passphrase.py +301 -0
- l3io/wp/backup/cli/preflight.py +227 -0
- l3io/wp/backup/cli/privileges.py +327 -0
- l3io/wp/backup/cli/restore.py +1571 -0
- l3io/wp/backup/cli/restore_report.py +396 -0
- l3io/wp/backup/cli/restore_request.py +560 -0
- l3io/wp/backup/cli/scope.py +31 -0
- l3io/wp/backup/cli/settings.py +524 -0
- l3io/wp/backup/cli/verify.py +286 -0
- l3io/wp/backup/core/__init__.py +9 -0
- l3io/wp/backup/core/archive_choice.py +156 -0
- l3io/wp/backup/core/backup.py +105 -0
- l3io/wp/backup/core/config_location.py +257 -0
- l3io/wp/backup/core/exclusions.py +189 -0
- l3io/wp/backup/core/relocation.py +437 -0
- l3io/wp/backup/core/restore.py +1246 -0
- l3io/wp/backup/core/restore_journal.py +541 -0
- l3io/wp/backup/core/restore_space.py +224 -0
- l3io/wp/backup/core/verify.py +198 -0
- l3io/wp/backup/domain/__init__.py +9 -0
- l3io/wp/backup/domain/archive_format.py +1072 -0
- l3io/wp/backup/domain/backup_scope.py +21 -0
- l3io/wp/backup/domain/config.py +27 -0
- l3io/wp/backup/domain/credentials.py +125 -0
- l3io/wp/backup/domain/database.py +113 -0
- l3io/wp/backup/domain/errors.py +1434 -0
- l3io/wp/backup/domain/exclusion.py +74 -0
- l3io/wp/backup/domain/exit_codes.py +87 -0
- l3io/wp/backup/domain/flag_map.py +246 -0
- l3io/wp/backup/domain/journal.py +142 -0
- l3io/wp/backup/domain/lock.py +61 -0
- l3io/wp/backup/domain/preflight.py +126 -0
- l3io/wp/backup/domain/restore.py +181 -0
- l3io/wp/backup/domain/search_replace.py +59 -0
- l3io/wp/backup/domain/secret.py +61 -0
- l3io/wp/backup/domain/site.py +60 -0
- l3io/wp/backup/domain/storage_target.py +60 -0
- l3io/wp/backup/ports/__init__.py +33 -0
- l3io/wp/backup/ports/adapter_set.py +66 -0
- l3io/wp/backup/ports/archive.py +136 -0
- l3io/wp/backup/ports/clock.py +23 -0
- l3io/wp/backup/ports/config.py +71 -0
- l3io/wp/backup/ports/crypto.py +30 -0
- l3io/wp/backup/ports/database.py +129 -0
- l3io/wp/backup/ports/exclusion.py +33 -0
- l3io/wp/backup/ports/journal.py +53 -0
- l3io/wp/backup/ports/lock.py +48 -0
- l3io/wp/backup/ports/preflight.py +23 -0
- l3io/wp/backup/ports/restore.py +59 -0
- l3io/wp/backup/ports/search_replace.py +40 -0
- l3io/wp/backup/ports/site_files.py +95 -0
- l3io/wp/backup/ports/tracing.py +80 -0
- l3io/wp/backup/py.typed +0 -0
- l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
- l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
- l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
- l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
- l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
- wpbackup2/__init__.py +7 -0
- wpbackup2/__main__.py +8 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""FR-14/AD-9 — the credential chains, in the domain's declared precedence.
|
|
2
|
+
|
|
3
|
+
Layer: ``adapters`` (AD-1). It imports ``domain/`` and ``adapters/redaction``;
|
|
4
|
+
``l3io.wp.database`` (a required dependency, owner decision 76) is imported
|
|
5
|
+
**inside the functions that use it** and, for annotations, under
|
|
6
|
+
``TYPE_CHECKING`` (AD-8).
|
|
7
|
+
|
|
8
|
+
Two builders put database's own sources in order and return a database
|
|
9
|
+
``CredentialChain`` (first source with an answer wins; a source that *failed*
|
|
10
|
+
stops the chain). They write no chain and no source of their own, call no
|
|
11
|
+
``resolve()`` and do no I/O:
|
|
12
|
+
|
|
13
|
+
- :func:`wordpress_credentials` — ``WORDPRESS_CREDENTIAL_PRECEDENCE``: the
|
|
14
|
+
spec (``Explicit`` or ``AwsSecretsManager``), then the environment
|
|
15
|
+
(:data:`WORDPRESS_USER_VAR`/:data:`WORDPRESS_PASSWORD_VAR`), then
|
|
16
|
+
``wp-config.php``.
|
|
17
|
+
- :func:`admin_credentials` — ``ADMIN_CREDENTIAL_PRECEDENCE``: the spec, then
|
|
18
|
+
the environment (:data:`ADMIN_USER_VAR`/:data:`ADMIN_PASSWORD_VAR`), and never
|
|
19
|
+
``wp-config.php``. ``None`` when there is no administrator source at all.
|
|
20
|
+
|
|
21
|
+
The environment step is included when either of its two variables is present
|
|
22
|
+
in ``environ``; database's ``Environment`` then reads ``os.environ`` when the
|
|
23
|
+
chain resolves, so the composition root passes ``os.environ``. A variable set
|
|
24
|
+
to ``""`` counts as set, and a password shorter than
|
|
25
|
+
``adapters/redaction.MIN_SECRET_LENGTH`` (the empty one included) is accepted
|
|
26
|
+
with WARNING ``credential_below_redaction_floor``, logged by
|
|
27
|
+
``adapters/database.py``'s wrapper when the chain resolves (owner decision 61,
|
|
28
|
+
amending ADR-0018). The one built exception is
|
|
29
|
+
``cli/credentials.py::admin_spec``'s ``--admin-password`` fallback, which
|
|
30
|
+
reads an empty ``L3IO_WP_BACKUP_ADMIN_PASSWORD`` as unset. One
|
|
31
|
+
variable of a pair set
|
|
32
|
+
without its partner is a failure, never an absence, so it does not fall
|
|
33
|
+
through to ``wp-config.php``.
|
|
34
|
+
|
|
35
|
+
An explicit spec's password is registered with the Run's ``SecretRegistry``
|
|
36
|
+
when its source is built, and a short one warned of there; every password the
|
|
37
|
+
chain resolves is registered by ``adapters/database.py``'s wrapper, the only
|
|
38
|
+
caller of these builders.
|
|
39
|
+
|
|
40
|
+
One source is this module's own, and it provides nothing new:
|
|
41
|
+
:func:`resolved_credentials` replays a ``ResolvedCredentialSpec``, the answer
|
|
42
|
+
a WordPress chain gave a backup the CLI started as root, before it switched to
|
|
43
|
+
the Site Root's owner (owner decision 67), under that chain's label.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from collections.abc import Mapping
|
|
49
|
+
from typing import TYPE_CHECKING, Final, Literal
|
|
50
|
+
|
|
51
|
+
import structlog
|
|
52
|
+
|
|
53
|
+
from l3io.wp.backup.adapters.redaction import (
|
|
54
|
+
MIN_SECRET_LENGTH,
|
|
55
|
+
note_below_redaction_floor,
|
|
56
|
+
)
|
|
57
|
+
from l3io.wp.backup.domain.credentials import (
|
|
58
|
+
ADMIN_CREDENTIAL_PRECEDENCE,
|
|
59
|
+
WORDPRESS_CREDENTIAL_PRECEDENCE,
|
|
60
|
+
AwsSecretCredentialSpec,
|
|
61
|
+
CredentialOrigin,
|
|
62
|
+
CredentialSpec,
|
|
63
|
+
ResolvedCredentialSpec,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from l3io.wp.database import CredentialSource, ResolvedCredentials
|
|
68
|
+
|
|
69
|
+
from l3io.wp.backup.adapters.redaction import SecretRegistry
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"ADMIN_PASSWORD_VAR",
|
|
73
|
+
"ADMIN_USER_VAR",
|
|
74
|
+
"WORDPRESS_PASSWORD_VAR",
|
|
75
|
+
"WORDPRESS_USER_VAR",
|
|
76
|
+
"admin_credentials",
|
|
77
|
+
"resolved_credentials",
|
|
78
|
+
"unset_partner",
|
|
79
|
+
"wordpress_credentials",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
_log = structlog.get_logger(__name__)
|
|
83
|
+
|
|
84
|
+
#: The WordPress account's variables. Database's ``DB_USER``/``DB_PASSWORD``
|
|
85
|
+
#: are deliberately not used: an unrelated tool may have set them.
|
|
86
|
+
WORDPRESS_USER_VAR: Final[str] = "L3IO_WP_BACKUP_DB_USER"
|
|
87
|
+
WORDPRESS_PASSWORD_VAR: Final[str] = "L3IO_WP_BACKUP_DB_PASSWORD" # noqa: S105 - a name
|
|
88
|
+
#: The administrator account's variables.
|
|
89
|
+
ADMIN_USER_VAR: Final[str] = "L3IO_WP_BACKUP_ADMIN_USER"
|
|
90
|
+
ADMIN_PASSWORD_VAR: Final[str] = "L3IO_WP_BACKUP_ADMIN_PASSWORD" # noqa: S105 - a name
|
|
91
|
+
|
|
92
|
+
_Role = Literal["wordpress", "administrator"]
|
|
93
|
+
_PAIRS: Final[Mapping[_Role, tuple[str, str]]] = {
|
|
94
|
+
"wordpress": (WORDPRESS_USER_VAR, WORDPRESS_PASSWORD_VAR),
|
|
95
|
+
"administrator": (ADMIN_USER_VAR, ADMIN_PASSWORD_VAR),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def unset_partner(environ: Mapping[str, str], role: _Role) -> str | None:
|
|
100
|
+
"""The unset variable of ``role``'s pair when only its partner is set."""
|
|
101
|
+
user, password = _PAIRS[role]
|
|
102
|
+
if (user in environ) == (password in environ):
|
|
103
|
+
return None
|
|
104
|
+
return password if user in environ else user
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _explicit_step(
|
|
108
|
+
spec: CredentialSpec | None, secrets: SecretRegistry
|
|
109
|
+
) -> CredentialSource | None:
|
|
110
|
+
if spec is None:
|
|
111
|
+
return None
|
|
112
|
+
from l3io.wp import database as wpdb
|
|
113
|
+
|
|
114
|
+
if isinstance(spec, AwsSecretCredentialSpec):
|
|
115
|
+
return wpdb.AwsSecretsManager(spec.secret_id, spec.region)
|
|
116
|
+
secrets.register(spec.secret, source="explicit")
|
|
117
|
+
if len(spec.secret) < MIN_SECRET_LENGTH:
|
|
118
|
+
# Owner decision 61 (amending ADR-0018): accepted, with a WARNING.
|
|
119
|
+
note_below_redaction_floor("explicit")
|
|
120
|
+
# Handed to l3io-wordpress-database: the one place it is revealed here.
|
|
121
|
+
return wpdb.Explicit(spec.username, spec.secret.reveal())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _environment_step(
|
|
125
|
+
environ: Mapping[str, str], role: _Role
|
|
126
|
+
) -> CredentialSource | None:
|
|
127
|
+
user, password = _PAIRS[role]
|
|
128
|
+
if user not in environ and password not in environ:
|
|
129
|
+
return None
|
|
130
|
+
from l3io.wp import database as wpdb
|
|
131
|
+
|
|
132
|
+
return wpdb.Environment(user, password)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ordered(
|
|
136
|
+
role: _Role,
|
|
137
|
+
precedence: tuple[CredentialOrigin, ...],
|
|
138
|
+
steps: Mapping[CredentialOrigin, CredentialSource | None],
|
|
139
|
+
) -> tuple[CredentialSource, ...]:
|
|
140
|
+
"""The present steps in ``precedence`` order, logged by label.
|
|
141
|
+
|
|
142
|
+
Every origin in ``precedence`` must have a step (``None`` for absent), so
|
|
143
|
+
an origin the domain adds without a builder step fails here, loudly.
|
|
144
|
+
"""
|
|
145
|
+
ordered = tuple(s for s in (steps[o] for o in precedence) if s is not None)
|
|
146
|
+
sources = tuple(s.source for s in ordered)
|
|
147
|
+
if ordered:
|
|
148
|
+
_log.debug("credential_chain_built", role=role, sources=sources)
|
|
149
|
+
else:
|
|
150
|
+
_log.debug(
|
|
151
|
+
"credential_chain_built", role=role, sources=sources, none_configured=True
|
|
152
|
+
)
|
|
153
|
+
return ordered
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def wordpress_credentials(
|
|
157
|
+
spec: CredentialSpec | None,
|
|
158
|
+
*,
|
|
159
|
+
wp_config: CredentialSource,
|
|
160
|
+
environ: Mapping[str, str],
|
|
161
|
+
secrets: SecretRegistry,
|
|
162
|
+
) -> CredentialSource:
|
|
163
|
+
"""The WordPress account's chain; ``wp_config`` is always its last step."""
|
|
164
|
+
from l3io.wp import database as wpdb
|
|
165
|
+
|
|
166
|
+
ordered = _ordered(
|
|
167
|
+
"wordpress",
|
|
168
|
+
WORDPRESS_CREDENTIAL_PRECEDENCE,
|
|
169
|
+
{
|
|
170
|
+
CredentialOrigin.EXPLICIT: _explicit_step(spec, secrets),
|
|
171
|
+
CredentialOrigin.ENVIRONMENT: _environment_step(environ, "wordpress"),
|
|
172
|
+
CredentialOrigin.WP_CONFIG: wp_config,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
return wpdb.CredentialChain(*ordered)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def admin_credentials(
|
|
179
|
+
spec: CredentialSpec | None,
|
|
180
|
+
*,
|
|
181
|
+
environ: Mapping[str, str],
|
|
182
|
+
secrets: SecretRegistry,
|
|
183
|
+
) -> CredentialSource | None:
|
|
184
|
+
"""The administrator's chain, or ``None`` when no source is configured.
|
|
185
|
+
|
|
186
|
+
There is no ``wp-config.php`` step and no WordPress account step, for any
|
|
187
|
+
input (AD-29): a missing administrator is refused where one is needed.
|
|
188
|
+
"""
|
|
189
|
+
ordered = _ordered(
|
|
190
|
+
"administrator",
|
|
191
|
+
ADMIN_CREDENTIAL_PRECEDENCE,
|
|
192
|
+
{
|
|
193
|
+
CredentialOrigin.EXPLICIT: _explicit_step(spec, secrets),
|
|
194
|
+
CredentialOrigin.ENVIRONMENT: _environment_step(environ, "administrator"),
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
if not ordered:
|
|
198
|
+
return None
|
|
199
|
+
from l3io.wp import database as wpdb
|
|
200
|
+
|
|
201
|
+
return wpdb.CredentialChain(*ordered)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _Resolved:
|
|
205
|
+
"""A ``CredentialSource`` replaying a :class:`ResolvedCredentialSpec`."""
|
|
206
|
+
|
|
207
|
+
__slots__ = ("_spec",)
|
|
208
|
+
|
|
209
|
+
def __init__(self, spec: ResolvedCredentialSpec) -> None:
|
|
210
|
+
self._spec = spec
|
|
211
|
+
|
|
212
|
+
def __repr__(self) -> str:
|
|
213
|
+
return f"Resolved({self.source!r})"
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def source(self) -> str:
|
|
217
|
+
return self._spec.chain
|
|
218
|
+
|
|
219
|
+
def resolve(self) -> ResolvedCredentials:
|
|
220
|
+
from l3io.wp import database as wpdb
|
|
221
|
+
|
|
222
|
+
spec = self._spec
|
|
223
|
+
# Handed to l3io-wordpress-database: revealed here only.
|
|
224
|
+
return wpdb.ResolvedCredentials(
|
|
225
|
+
spec.username, spec.secret.reveal(), spec.source
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def resolved_credentials(spec: ResolvedCredentialSpec) -> CredentialSource:
|
|
230
|
+
"""The source answering with ``spec`` under its chain's label (decision 67).
|
|
231
|
+
|
|
232
|
+
No I/O and no registration: the password was registered, and a short one
|
|
233
|
+
warned of, when the chain resolved it; the caller registers it with the
|
|
234
|
+
Run's registry.
|
|
235
|
+
"""
|
|
236
|
+
return _Resolved(spec)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""ADR-0014/ADR-0024, AD-26 — the passphrase payload cipher, via ``l3io-wp-database``.
|
|
2
|
+
|
|
3
|
+
:class:`PassphraseCipher` implements the ``PayloadCipher`` port by calling
|
|
4
|
+
``l3io.wp.database.encryption``'s ``encrypt``/``decrypt`` with ``passphrase=``;
|
|
5
|
+
no cipher, KDF or ``age`` code is written or imported here (ADR-0014). The
|
|
6
|
+
database import is deferred (AD-8). Database, ``pyrage`` and the binder are
|
|
7
|
+
required dependencies (owner decision 76), so an installation that lacks one,
|
|
8
|
+
or that has neither the binder nor the ``age`` command line as a backend,
|
|
9
|
+
raises ``IncompleteInstallError`` naming the missing distribution. It is the one
|
|
10
|
+
``PayloadCipher`` in the package (BL-E004-042), and every composition root
|
|
11
|
+
builds it through :func:`build_payload_cipher` (E004-S01-003).
|
|
12
|
+
|
|
13
|
+
Database's errors are mapped into this package's hierarchy (AD-14) and kept
|
|
14
|
+
distinct: a passphrase that cannot open the payload is
|
|
15
|
+
:class:`PassphraseRejectedError`, a damaged, truncated or non-age stream is
|
|
16
|
+
:class:`PayloadDamagedError`. Both are ``VERIFICATION_FAILED`` (Shawn,
|
|
17
|
+
2026-11-03): ``age`` authenticates every chunk, so either means the payload
|
|
18
|
+
could not be authenticated. A passphrase or backend database refuses as
|
|
19
|
+
invalid is :class:`EncryptionConfigError` (``USAGE``). No message carries
|
|
20
|
+
database's text, and none carries the passphrase.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib.util
|
|
26
|
+
import os
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from typing import IO, Final, overload
|
|
29
|
+
|
|
30
|
+
from l3io.wp.backup.adapters.preflight import has_relative_path_entry
|
|
31
|
+
from l3io.wp.backup.adapters.redaction import SecretRegistry
|
|
32
|
+
from l3io.wp.backup.domain.errors import (
|
|
33
|
+
IncompleteInstallError,
|
|
34
|
+
PassphraseRejectedError,
|
|
35
|
+
PayloadDamagedError,
|
|
36
|
+
UsageError,
|
|
37
|
+
)
|
|
38
|
+
from l3io.wp.backup.domain.preflight import ToolRequirement
|
|
39
|
+
from l3io.wp.backup.domain.secret import Secret, as_secret
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"EncryptionConfigError",
|
|
43
|
+
"PassphraseCipher",
|
|
44
|
+
"build_payload_cipher",
|
|
45
|
+
"passphrase_backend_requirements",
|
|
46
|
+
"selected_backend",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
_DISTRIBUTION: Final = "l3io-wordpress-database"
|
|
50
|
+
#: Database's backend switch (its README and ADR-0003).
|
|
51
|
+
_BACKEND_ENV: Final = "L3IO_AGE_PASSPHRASE_BACKEND"
|
|
52
|
+
_BINDER_MODULE: Final = "l3io_age_passphrase"
|
|
53
|
+
#: ``age`` 1.3 is the first release that ships ``age-plugin-batchpass`` (FR-27).
|
|
54
|
+
_AGE_MINIMUM: Final = (1, 3)
|
|
55
|
+
_NEEDED_FOR: Final = "the encrypted payload (ENCRYPT, VERIFY)"
|
|
56
|
+
_REGISTRY_SOURCE: Final = "passphrase"
|
|
57
|
+
#: The encryption a :class:`PassphraseRejectedError` names (``scheme``).
|
|
58
|
+
_SCHEME: Final = "age"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EncryptionConfigError(UsageError):
|
|
62
|
+
"""Database refused the passphrase or its backend setting as invalid.
|
|
63
|
+
|
|
64
|
+
Database's ``InvalidArgumentsError`` from ``check_passphrase``: an empty
|
|
65
|
+
passphrase, an unrecognised ``L3IO_AGE_PASSPHRASE_BACKEND``, or the ``cli``
|
|
66
|
+
backend without ``age`` and ``age-plugin-batchpass`` on ``PATH``. The
|
|
67
|
+
message never carries database's text, which could echo the input.
|
|
68
|
+
``relative_path`` is ``True`` for the ``PATH`` refusal; ``arguments``
|
|
69
|
+
names what is at fault (``PATH``, or the passphrase and the backend
|
|
70
|
+
variable).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *, relative_path: bool = False) -> None:
|
|
74
|
+
self.relative_path = relative_path
|
|
75
|
+
if relative_path:
|
|
76
|
+
super().__init__(
|
|
77
|
+
"encryption cannot run: the age command-line backend would be "
|
|
78
|
+
"looked up through a PATH with an empty or relative entry, so a "
|
|
79
|
+
"planted age could receive the passphrase; remove those entries "
|
|
80
|
+
"or use the in-process backend",
|
|
81
|
+
arguments=("PATH",),
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
super().__init__(
|
|
85
|
+
"encryption cannot run: the passphrase is empty, or "
|
|
86
|
+
f"{_BACKEND_ENV} names a backend that is unknown or not installed",
|
|
87
|
+
arguments=("passphrase", _BACKEND_ENV),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def passphrase_backend_requirements(
|
|
92
|
+
*, environ: Mapping[str, str], needed_for: str
|
|
93
|
+
) -> tuple[ToolRequirement, ...]:
|
|
94
|
+
"""The external tools database's passphrase backend needs (Gate E004 AG-8).
|
|
95
|
+
|
|
96
|
+
Restates database's documented selection (README, ADR-0003), because its
|
|
97
|
+
resolver is private: ``binder`` needs no binary; ``auto`` (or unset) needs
|
|
98
|
+
none when the ``l3io-age-passphrase`` binder is importable (detected with
|
|
99
|
+
``find_spec``, never imported), else ``age`` >= 1.3 and
|
|
100
|
+
``age-plugin-batchpass``, which ``cli`` always needs. An unrecognised value
|
|
101
|
+
needs nothing here: ``check()`` refuses it, in one place. Reads only
|
|
102
|
+
``environ``. Replace with a call if database exports a backend query.
|
|
103
|
+
"""
|
|
104
|
+
if selected_backend(environ) == "cli":
|
|
105
|
+
return (
|
|
106
|
+
ToolRequirement("age", _AGE_MINIMUM, needed_for),
|
|
107
|
+
ToolRequirement("age-plugin-batchpass", None, needed_for),
|
|
108
|
+
)
|
|
109
|
+
return ()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def selected_backend(environ: Mapping[str, str]) -> str:
|
|
113
|
+
"""Database's backend for ``environ``: ``cli``, ``binder`` or the raw value.
|
|
114
|
+
|
|
115
|
+
``auto`` (or unset) resolves to ``binder`` when the binder is importable
|
|
116
|
+
and to ``cli`` when it is not; an unrecognised value is returned as given.
|
|
117
|
+
"""
|
|
118
|
+
requested = environ.get(_BACKEND_ENV, "auto").strip().lower() or "auto"
|
|
119
|
+
if requested == "auto":
|
|
120
|
+
return "cli" if importlib.util.find_spec(_BINDER_MODULE) is None else "binder"
|
|
121
|
+
return requested
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PassphraseCipher:
|
|
125
|
+
"""``PayloadCipher`` over database's ``age`` passphrase streams."""
|
|
126
|
+
|
|
127
|
+
__slots__ = ("_passphrase",)
|
|
128
|
+
|
|
129
|
+
def __init__(self, passphrase: Secret | str) -> None:
|
|
130
|
+
self._passphrase = as_secret(passphrase)
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return "PassphraseCipher(passphrase=<redacted>)"
|
|
134
|
+
|
|
135
|
+
def required_tools(self) -> tuple[ToolRequirement, ...]:
|
|
136
|
+
"""The backend's external tools, for the plan's ``extra_tools``."""
|
|
137
|
+
return passphrase_backend_requirements(
|
|
138
|
+
environ=os.environ, needed_for=_NEEDED_FOR
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def check(self) -> None:
|
|
142
|
+
"""Refuse before any write if the passphrase or its backend cannot run.
|
|
143
|
+
|
|
144
|
+
``IncompleteInstallError`` when database, ``pyrage`` or a usable
|
|
145
|
+
backend is missing; ``EncryptionConfigError`` for an invalid
|
|
146
|
+
passphrase or backend setting. Also ``EncryptionConfigError`` when the
|
|
147
|
+
``cli`` backend is selected (explicitly, or because the binder is
|
|
148
|
+
unavailable) and ``PATH`` has an empty or relative entry: database
|
|
149
|
+
resolves ``age`` through it and hands it the passphrase, while the
|
|
150
|
+
preflight ignores such entries (AR-3, RT-7).
|
|
151
|
+
"""
|
|
152
|
+
if selected_backend(os.environ) == "cli" and has_relative_path_entry():
|
|
153
|
+
raise EncryptionConfigError(relative_path=True)
|
|
154
|
+
try:
|
|
155
|
+
from l3io.wp.database import encryption
|
|
156
|
+
from l3io.wp.database.errors import (
|
|
157
|
+
InvalidArgumentsError,
|
|
158
|
+
MissingDependencyError,
|
|
159
|
+
)
|
|
160
|
+
except ImportError as exc:
|
|
161
|
+
raise IncompleteInstallError(_DISTRIBUTION) from exc
|
|
162
|
+
try:
|
|
163
|
+
encryption.check_passphrase(self._passphrase.reveal())
|
|
164
|
+
except MissingDependencyError as exc:
|
|
165
|
+
raise IncompleteInstallError(exc.distribution) from exc
|
|
166
|
+
except InvalidArgumentsError as exc:
|
|
167
|
+
raise EncryptionConfigError from exc
|
|
168
|
+
|
|
169
|
+
def encrypt(self, reader: IO[bytes], writer: IO[bytes]) -> None:
|
|
170
|
+
"""Stream ``reader`` into ``writer`` as ``age`` (a sole scrypt recipient)."""
|
|
171
|
+
try:
|
|
172
|
+
from l3io.wp.database import encryption
|
|
173
|
+
from l3io.wp.database.errors import (
|
|
174
|
+
InvalidArgumentsError,
|
|
175
|
+
MissingDependencyError,
|
|
176
|
+
)
|
|
177
|
+
except ImportError as exc:
|
|
178
|
+
raise IncompleteInstallError(_DISTRIBUTION) from exc
|
|
179
|
+
try:
|
|
180
|
+
encryption.encrypt(reader, writer, passphrase=self._passphrase.reveal())
|
|
181
|
+
except MissingDependencyError as exc:
|
|
182
|
+
raise IncompleteInstallError(exc.distribution) from exc
|
|
183
|
+
except InvalidArgumentsError as exc:
|
|
184
|
+
raise EncryptionConfigError from exc
|
|
185
|
+
|
|
186
|
+
def decrypt(self, reader: IO[bytes], writer: IO[bytes]) -> None:
|
|
187
|
+
"""Stream ``age`` ciphertext into ``writer``, authenticating every chunk."""
|
|
188
|
+
try:
|
|
189
|
+
from l3io.wp.database import encryption
|
|
190
|
+
from l3io.wp.database.errors import (
|
|
191
|
+
CorruptDumpError,
|
|
192
|
+
DecryptionError,
|
|
193
|
+
InvalidArgumentsError,
|
|
194
|
+
MissingDependencyError,
|
|
195
|
+
)
|
|
196
|
+
except ImportError as exc:
|
|
197
|
+
raise IncompleteInstallError(_DISTRIBUTION) from exc
|
|
198
|
+
try:
|
|
199
|
+
encryption.decrypt(reader, writer, passphrase=self._passphrase.reveal())
|
|
200
|
+
except MissingDependencyError as exc:
|
|
201
|
+
raise IncompleteInstallError(exc.distribution) from exc
|
|
202
|
+
except InvalidArgumentsError as exc:
|
|
203
|
+
raise EncryptionConfigError from exc
|
|
204
|
+
except DecryptionError as exc:
|
|
205
|
+
raise PassphraseRejectedError(scheme=_SCHEME) from exc
|
|
206
|
+
except CorruptDumpError as exc:
|
|
207
|
+
raise PayloadDamagedError from exc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@overload
|
|
211
|
+
def build_payload_cipher(
|
|
212
|
+
*, passphrase: str | Secret, secrets: SecretRegistry
|
|
213
|
+
) -> PassphraseCipher: ...
|
|
214
|
+
@overload
|
|
215
|
+
def build_payload_cipher(*, passphrase: None, secrets: SecretRegistry) -> None: ...
|
|
216
|
+
@overload
|
|
217
|
+
def build_payload_cipher(
|
|
218
|
+
*, passphrase: str | Secret | None, secrets: SecretRegistry
|
|
219
|
+
) -> PassphraseCipher | None: ...
|
|
220
|
+
def build_payload_cipher(
|
|
221
|
+
*, passphrase: str | Secret | None, secrets: SecretRegistry
|
|
222
|
+
) -> PassphraseCipher | None:
|
|
223
|
+
"""The one way a composition root turns a resolved passphrase into a cipher.
|
|
224
|
+
|
|
225
|
+
``None`` for ``None``: whether a passphrase was required is the root's
|
|
226
|
+
call (``require_passphrase``, ``_require_cipher``, ``--authenticate``).
|
|
227
|
+
Otherwise the passphrase is registered for redaction (idempotent: the
|
|
228
|
+
AD-26 chain registered it already), and the cipher's ``check()`` runs
|
|
229
|
+
before it is returned, so every root refuses identically, Dry Run too.
|
|
230
|
+
"""
|
|
231
|
+
if passphrase is None:
|
|
232
|
+
return None
|
|
233
|
+
secret = as_secret(passphrase)
|
|
234
|
+
secrets.register(secret, source=_REGISTRY_SOURCE)
|
|
235
|
+
cipher = PassphraseCipher(secret)
|
|
236
|
+
cipher.check()
|
|
237
|
+
return cipher
|