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.
Files changed (98) hide show
  1. l3io/wp/backup/__init__.py +22 -0
  2. l3io/wp/backup/_version.py +31 -0
  3. l3io/wp/backup/adapters/__init__.py +9 -0
  4. l3io/wp/backup/adapters/clock.py +22 -0
  5. l3io/wp/backup/adapters/config_writer.py +702 -0
  6. l3io/wp/backup/adapters/credential_providers.py +236 -0
  7. l3io/wp/backup/adapters/crypto.py +237 -0
  8. l3io/wp/backup/adapters/database.py +1555 -0
  9. l3io/wp/backup/adapters/database_load.py +1263 -0
  10. l3io/wp/backup/adapters/exclusions.py +83 -0
  11. l3io/wp/backup/adapters/journal.py +507 -0
  12. l3io/wp/backup/adapters/lock.py +372 -0
  13. l3io/wp/backup/adapters/noop.py +666 -0
  14. l3io/wp/backup/adapters/phase.py +218 -0
  15. l3io/wp/backup/adapters/preflight.py +869 -0
  16. l3io/wp/backup/adapters/redaction.py +237 -0
  17. l3io/wp/backup/adapters/restore_extension.py +341 -0
  18. l3io/wp/backup/adapters/restore_runner.py +1030 -0
  19. l3io/wp/backup/adapters/site_files.py +191 -0
  20. l3io/wp/backup/adapters/store.py +227 -0
  21. l3io/wp/backup/adapters/tar.py +1648 -0
  22. l3io/wp/backup/adapters/tracing.py +235 -0
  23. l3io/wp/backup/adapters/workspace.py +299 -0
  24. l3io/wp/backup/adapters/wpcli.py +584 -0
  25. l3io/wp/backup/adapters/wpcli_child.py +135 -0
  26. l3io/wp/backup/api/__init__.py +104 -0
  27. l3io/wp/backup/api/errors.py +344 -0
  28. l3io/wp/backup/api/operations.py +427 -0
  29. l3io/wp/backup/cli/__init__.py +228 -0
  30. l3io/wp/backup/cli/adapter_sets.py +255 -0
  31. l3io/wp/backup/cli/backup.py +1118 -0
  32. l3io/wp/backup/cli/context.py +382 -0
  33. l3io/wp/backup/cli/credentials.py +310 -0
  34. l3io/wp/backup/cli/doctor.py +171 -0
  35. l3io/wp/backup/cli/exit.py +171 -0
  36. l3io/wp/backup/cli/logging.py +147 -0
  37. l3io/wp/backup/cli/output.py +111 -0
  38. l3io/wp/backup/cli/parser.py +853 -0
  39. l3io/wp/backup/cli/passphrase.py +301 -0
  40. l3io/wp/backup/cli/preflight.py +227 -0
  41. l3io/wp/backup/cli/privileges.py +327 -0
  42. l3io/wp/backup/cli/restore.py +1571 -0
  43. l3io/wp/backup/cli/restore_report.py +396 -0
  44. l3io/wp/backup/cli/restore_request.py +560 -0
  45. l3io/wp/backup/cli/scope.py +31 -0
  46. l3io/wp/backup/cli/settings.py +524 -0
  47. l3io/wp/backup/cli/verify.py +286 -0
  48. l3io/wp/backup/core/__init__.py +9 -0
  49. l3io/wp/backup/core/archive_choice.py +156 -0
  50. l3io/wp/backup/core/backup.py +105 -0
  51. l3io/wp/backup/core/config_location.py +257 -0
  52. l3io/wp/backup/core/exclusions.py +189 -0
  53. l3io/wp/backup/core/relocation.py +437 -0
  54. l3io/wp/backup/core/restore.py +1246 -0
  55. l3io/wp/backup/core/restore_journal.py +541 -0
  56. l3io/wp/backup/core/restore_space.py +224 -0
  57. l3io/wp/backup/core/verify.py +198 -0
  58. l3io/wp/backup/domain/__init__.py +9 -0
  59. l3io/wp/backup/domain/archive_format.py +1072 -0
  60. l3io/wp/backup/domain/backup_scope.py +21 -0
  61. l3io/wp/backup/domain/config.py +27 -0
  62. l3io/wp/backup/domain/credentials.py +125 -0
  63. l3io/wp/backup/domain/database.py +113 -0
  64. l3io/wp/backup/domain/errors.py +1434 -0
  65. l3io/wp/backup/domain/exclusion.py +74 -0
  66. l3io/wp/backup/domain/exit_codes.py +87 -0
  67. l3io/wp/backup/domain/flag_map.py +246 -0
  68. l3io/wp/backup/domain/journal.py +142 -0
  69. l3io/wp/backup/domain/lock.py +61 -0
  70. l3io/wp/backup/domain/preflight.py +126 -0
  71. l3io/wp/backup/domain/restore.py +181 -0
  72. l3io/wp/backup/domain/search_replace.py +59 -0
  73. l3io/wp/backup/domain/secret.py +61 -0
  74. l3io/wp/backup/domain/site.py +60 -0
  75. l3io/wp/backup/domain/storage_target.py +60 -0
  76. l3io/wp/backup/ports/__init__.py +33 -0
  77. l3io/wp/backup/ports/adapter_set.py +66 -0
  78. l3io/wp/backup/ports/archive.py +136 -0
  79. l3io/wp/backup/ports/clock.py +23 -0
  80. l3io/wp/backup/ports/config.py +71 -0
  81. l3io/wp/backup/ports/crypto.py +30 -0
  82. l3io/wp/backup/ports/database.py +129 -0
  83. l3io/wp/backup/ports/exclusion.py +33 -0
  84. l3io/wp/backup/ports/journal.py +53 -0
  85. l3io/wp/backup/ports/lock.py +48 -0
  86. l3io/wp/backup/ports/preflight.py +23 -0
  87. l3io/wp/backup/ports/restore.py +59 -0
  88. l3io/wp/backup/ports/search_replace.py +40 -0
  89. l3io/wp/backup/ports/site_files.py +95 -0
  90. l3io/wp/backup/ports/tracing.py +80 -0
  91. l3io/wp/backup/py.typed +0 -0
  92. l3io_wordpress_backup-1.0.0.dist-info/METADATA +593 -0
  93. l3io_wordpress_backup-1.0.0.dist-info/RECORD +98 -0
  94. l3io_wordpress_backup-1.0.0.dist-info/WHEEL +4 -0
  95. l3io_wordpress_backup-1.0.0.dist-info/entry_points.txt +2 -0
  96. l3io_wordpress_backup-1.0.0.dist-info/licenses/LICENSE +21 -0
  97. wpbackup2/__init__.py +7 -0
  98. 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