datavalv-client 0.5.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.
@@ -0,0 +1,45 @@
1
+ """Datavalv's Python client: the file format, the crypto, and the four calls.
2
+
3
+ `crypto` and `format` are the primitives, checked against the TypeScript
4
+ implementation by the conformance matrix. `client.DatavalvClient` is the HTTP
5
+ layer over them, and `cli` is the `datavalv` command an operator runs.
6
+ """
7
+
8
+ from .client import CreatedVault, DatavalvClient, PushedBackup
9
+ from .errors import (
10
+ ApiError,
11
+ AuthError,
12
+ ConfigurationError,
13
+ DatavalvError,
14
+ NetworkError,
15
+ QuotaExceeded,
16
+ RequestInFlight,
17
+ StorageFull,
18
+ UploadError,
19
+ VaultKeyChanged,
20
+ VaultNotInitialized,
21
+ )
22
+ from .pins import FilePins, KeyPins, MemoryPins
23
+
24
+ __version__ = "0.5.0"
25
+
26
+ __all__ = [
27
+ "ApiError",
28
+ "AuthError",
29
+ "ConfigurationError",
30
+ "CreatedVault",
31
+ "DatavalvClient",
32
+ "DatavalvError",
33
+ "FilePins",
34
+ "KeyPins",
35
+ "MemoryPins",
36
+ "NetworkError",
37
+ "PushedBackup",
38
+ "QuotaExceeded",
39
+ "RequestInFlight",
40
+ "StorageFull",
41
+ "UploadError",
42
+ "VaultKeyChanged",
43
+ "VaultNotInitialized",
44
+ "__version__",
45
+ ]
@@ -0,0 +1,29 @@
1
+ """Generated by scripts/gen_constants.py from format/v1.json. DO NOT EDIT."""
2
+
3
+ MAGIC = b'DVLV'
4
+ VERSION = 1
5
+ ALG_ID = 1
6
+ IV_LENGTH = 12
7
+ TAG_LENGTH = 16
8
+ HEADER_LENGTH = 21
9
+ RESERVED = b"\x00\x00"
10
+
11
+ AES_KEY_BITS = 256
12
+ RSA_MODULUS_BITS = 3072
13
+
14
+ HKDF_INFO = {
15
+ "invite": "datavalv/invite/v1",
16
+ "kek": "datavalv/kek/v1",
17
+ "login": "datavalv/login/v1",
18
+ "recovery": "datavalv/recovery/v1"
19
+ }
20
+
21
+ ARGON2ID = {
22
+ "hashLength": 32,
23
+ "memoryKiB": 65536,
24
+ "parallelism": 1,
25
+ "saltPrefix": "datavalv/v1:",
26
+ "timeCost": 3
27
+ }
28
+
29
+ LOGIN_HASH_PREFIX = 'Dv1!'
datavalv_client/cli.py ADDED
@@ -0,0 +1,537 @@
1
+ """`datavalv` — the operator's side of connecting a server, as three commands.
2
+
3
+ What `/connect-system` asks of an operator is a keypair made with `openssl`, a
4
+ registration in the browser, a JWT minted per call because a token is single
5
+ use, and four HTTP calls in one order with an `x-amz-checksum-sha256` header on
6
+ the third. Two of those are genuinely irreducible: the keypair has to be made on
7
+ the machine that will sign with it, and the registration has to be done by a
8
+ signed-in person. The rest is this:
9
+
10
+ datavalv keygen # a keypair; the public half to paste
11
+ datavalv vault create --external-id … # one vault, for one customer
12
+ datavalv push dump.sql.gz --external-id …
13
+
14
+ **Registration is not here and will not be.** `POST /v1/apps` sits on the
15
+ Cognito authorizer, so it needs a signed-in account — and a command-line tool
16
+ that asked for a Datavalv password would be asking for the one password this
17
+ product is built never to see. `keygen` prints the public half; a person pastes
18
+ it into `/applications` and gets back the two ids.
19
+
20
+ Settings come from flags, falling back to the environment:
21
+
22
+ DATAVALV_API https://api.datavalv.se
23
+ DATAVALV_CLIENT_ID what /applications issued
24
+ DATAVALV_KEY_ID the kid of the registered signing key
25
+ DATAVALV_PRIVATE_KEY a PATH to the key file — never the key itself
26
+
27
+ That last one is a path on purpose. A private key in an environment variable is
28
+ in `ps`, in the shell history of whatever set it, and in every crash dump of the
29
+ process that read it.
30
+
31
+ Identifiers go to stdout and everything else to stderr, so
32
+ `BACKUP=$(datavalv push …)` holds a backup id and nothing else.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import os
39
+ import stat
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ import httpx
44
+
45
+ from . import crypto
46
+ from .client import DatavalvClient
47
+ from .errors import ApiError, AuthError, ConfigurationError, DatavalvError, VaultKeyChanged
48
+ from .pins import FilePins
49
+
50
+ #: Exit codes, for a schedule that branches on them.
51
+ #:
52
+ #: `RETRY` is the one that earns its keep: a vault whose owner has not signed in
53
+ #: yet answers `409 tenant_not_initialized`, and a cron job that treats that as
54
+ #: a failure pages somebody about a server that is working perfectly. Anything
55
+ #: under `RETRY` is worth running again unchanged; anything under `REFUSED` or
56
+ #: `CONFIG` needs a person.
57
+ EXIT_OK = 0
58
+ EXIT_FAILED = 1
59
+ EXIT_USAGE = 2 # argparse's own, restated here so the table is complete
60
+ EXIT_CONFIG = 3
61
+ EXIT_AUTH = 4
62
+ EXIT_RETRY = 5
63
+ EXIT_REFUSED = 6
64
+ #: Neither of its neighbours. 5 would have a schedule push again every night
65
+ #: against a key that is wrong every night; 6 says the *service* refused, when
66
+ #: this is the client refusing the service. It needs a person, and it needs them
67
+ #: to know which thing to go and compare.
68
+ EXIT_KEY_CHANGED = 7
69
+
70
+ DEFAULT_KEY_FILENAME = "datavalv-signing.pem"
71
+
72
+ _EXIT_TABLE = """exit codes:
73
+ 0 done
74
+ 1 failed for a reason not covered below
75
+ 2 the command line was wrong
76
+ 3 a setting, or a file named on the command line, is missing or unusable
77
+ 4 the signing key, client id or key id was refused
78
+ 5 nothing is wrong; run it again later (vault not started yet, network, quota)
79
+ 6 the service refused the request and a person must look at it
80
+ 7 the key offered for the vault is not the one on record; nothing was sent"""
81
+
82
+
83
+ # ---------------------------------------------------------------- settings
84
+
85
+
86
+ def _resolve(value: str | None, env_name: str, flag: str, what: str) -> str:
87
+ resolved = value or os.environ.get(env_name) or ""
88
+ if not resolved.strip():
89
+ raise ConfigurationError(f"no {what}: pass {flag} or set {env_name}")
90
+ return resolved.strip()
91
+
92
+
93
+ def _read_private_key(path_text: str) -> str:
94
+ """The signing key's PEM, off disk, complaining about a readable file.
95
+
96
+ The mode check is a warning rather than a refusal. A key that others can
97
+ read is a real problem and the operator should be told at the moment they
98
+ are reminded it exists — but refusing to push a backup over a permission bit
99
+ would turn a warning into a missed night's copy, which is the worse of the
100
+ two.
101
+
102
+ **The key itself is refused here, and its value is never echoed.** A PEM in
103
+ `DATAVALV_PRIVATE_KEY` is the mistake the variable is a path to prevent, and
104
+ the natural failure — `Path(pem).read_text()` raising, reported with the
105
+ filename in it — would print the operator's private key to stderr and into
106
+ whatever collects their cron output.
107
+ """
108
+ if "-----BEGIN" in path_text:
109
+ raise ConfigurationError(
110
+ "the signing key was given as key text rather than as a path to it. "
111
+ "DATAVALV_PRIVATE_KEY names a file: a private key in an environment "
112
+ "variable is visible in ps, in the shell history that set it and in "
113
+ "any crash dump. Write it to a file with mode 600 and pass the path. "
114
+ "(The value is not repeated here, for the same reason.)"
115
+ )
116
+
117
+ path = Path(path_text).expanduser()
118
+ try:
119
+ pem = path.read_text()
120
+ except OSError as exc:
121
+ raise ConfigurationError(
122
+ f"cannot read the signing key at {path}: {exc.strerror or exc}"
123
+ ) from exc
124
+
125
+ if "-----BEGIN" not in pem:
126
+ raise ConfigurationError(
127
+ f"{path} does not look like a PEM key. DATAVALV_PRIVATE_KEY is a path "
128
+ "to the key file, not the key."
129
+ )
130
+
131
+ mode = path.stat().st_mode
132
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
133
+ print(
134
+ f"warning: {path} is readable by others (mode {stat.filemode(mode)}); "
135
+ "chmod 600 it",
136
+ file=sys.stderr,
137
+ )
138
+ return pem
139
+
140
+
141
+ def _client(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> DatavalvClient:
142
+ api = _resolve(args.api, "DATAVALV_API", "--api", "API address")
143
+ client_id = _resolve(args.client_id, "DATAVALV_CLIENT_ID", "--client-id", "client id")
144
+ key_id = _resolve(args.key_id, "DATAVALV_KEY_ID", "--key-id", "key id")
145
+ key_path = _resolve(
146
+ args.private_key, "DATAVALV_PRIVATE_KEY", "--private-key", "signing key"
147
+ )
148
+ return DatavalvClient(
149
+ api,
150
+ client_id,
151
+ key_id,
152
+ _read_private_key(key_path),
153
+ transport=transport,
154
+ )
155
+
156
+
157
+ # ---------------------------------------------------------------- commands
158
+
159
+
160
+ def _keygen(args: argparse.Namespace) -> int:
161
+ """An RSA-3072 keypair: the private half to a file only its owner can read,
162
+ the public half to stdout.
163
+
164
+ **The private half is never printed and never sent anywhere.** It exists in
165
+ this process only long enough to be written to the file descriptor opened
166
+ below, which is created with mode `0600` before a byte is in it — writing it
167
+ world-readable and then fixing the mode leaves a window in which it is
168
+ readable, and a window is all it takes.
169
+ """
170
+ path = Path(args.out).expanduser()
171
+
172
+ if path.exists() and not args.force:
173
+ print(
174
+ f"{path} already exists. Refusing to replace a signing key that may "
175
+ "still be registered — pass --force if that is what you mean, and "
176
+ "retire the old key in /applications afterwards.",
177
+ file=sys.stderr,
178
+ )
179
+ return EXIT_FAILED
180
+
181
+ private_key = crypto.generate_keypair()
182
+
183
+ try:
184
+ if args.force and path.exists():
185
+ # Unlinked rather than truncated, so the descriptor below is
186
+ # created fresh at 0600. Truncating an existing file keeps whatever
187
+ # mode that file already had, which may be the mode that made
188
+ # replacing the key necessary.
189
+ path.unlink()
190
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
191
+ except OSError as exc:
192
+ print(f"cannot write {path}: {exc.strerror or exc}", file=sys.stderr)
193
+ return EXIT_FAILED
194
+
195
+ with os.fdopen(fd, "w") as handle:
196
+ handle.write(crypto.private_key_to_pem(private_key))
197
+ # Explicit, because the mode passed to `os.open` is masked by the umask and
198
+ # a umask that clears the owner's write bit would leave a key file its owner
199
+ # cannot rotate. This sets exactly 0600 — it can give the owner their own
200
+ # write bit back, and it grants nothing to group or other under any umask.
201
+ #
202
+ # It is a tidy-up, not the protection: the file was created at 0600 above,
203
+ # so there is no moment at which it exists readable. Deleting this line
204
+ # leaves the key safe; making `os.open` create it at 0644 does not, which is
205
+ # what `test_keygen_creates_the_key_at_0600_rather_than_fixing_it_afterwards`
206
+ # holds apart by neutering this call.
207
+ os.chmod(path, 0o600)
208
+
209
+ public_pem = crypto.public_key_to_pem(private_key.public_key())
210
+ print(public_pem, end="" if public_pem.endswith("\n") else "\n")
211
+
212
+ print(
213
+ f"\nThe private half is in {path}, mode 600, and must not leave this "
214
+ "machine.\nPaste the block above into /applications in the browser — "
215
+ "sign in first — and\nkeep the two values it answers with:\n"
216
+ " DATAVALV_CLIENT_ID identifies this server\n"
217
+ " DATAVALV_KEY_ID identifies this key",
218
+ file=sys.stderr,
219
+ )
220
+ return EXIT_OK
221
+
222
+
223
+ def _vault_create(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
224
+ with _client(args, transport) as client:
225
+ vault = client.create_vault(
226
+ args.external_id,
227
+ args.owner_email,
228
+ name=args.name,
229
+ locale=args.locale,
230
+ retention_count=args.retention,
231
+ )
232
+
233
+ print(vault.tenant_id)
234
+ print(
235
+ f"Vault {vault.tenant_id} is {vault.status}, and {args.owner_email} has been "
236
+ "emailed.\n"
237
+ "That address is the whole of the connection: the vault appears only to an "
238
+ "account\nwhose confirmed address is identical to it, and a mistyped one is "
239
+ "reported to\nnobody. Until the owner signs in and starts the vault, a push "
240
+ "answers\n409 tenant_not_initialized — which is correct, and exit code 5.",
241
+ file=sys.stderr,
242
+ )
243
+ return EXIT_OK
244
+
245
+
246
+ def _push(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
247
+ data, name = _read_payload(args)
248
+
249
+ with _client(args, transport) as client:
250
+ pushed = client.push_backup(
251
+ args.external_id,
252
+ data,
253
+ name=name,
254
+ label=args.label,
255
+ idempotency_key=args.idempotency_key,
256
+ )
257
+
258
+ print(pushed.backup_id)
259
+ print(
260
+ f"{pushed.size} bytes encrypted and stored as {pushed.backup_id}"
261
+ + (f"; {pushed.pruned} older copy(ies) pruned" if pushed.pruned else ""),
262
+ file=sys.stderr,
263
+ )
264
+ if pushed.key_first_seen:
265
+ # Once, and never again for this vault. A line repeated every night is
266
+ # a line nobody reads, and the night it mattered would look like all
267
+ # the others.
268
+ print(
269
+ f"\nThis is the first copy this machine has pushed to {args.external_id}. "
270
+ "It was encrypted to the key\nthe service offered, whose fingerprint is\n\n"
271
+ f" {pushed.key_fingerprint}\n\n"
272
+ "That key is now on record here and a different one will be refused. To know "
273
+ "it is the vault's\nand not merely the first one offered, ask the vault's owner "
274
+ "for the fingerprint shown under their backups\n-- it is worked out in their "
275
+ "browser from the key they hold -- and check that it is the same.",
276
+ file=sys.stderr,
277
+ )
278
+ return EXIT_OK
279
+
280
+
281
+ def _vault_key(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
282
+ """Show, trust or forget the key on record for one vault.
283
+
284
+ **`--trust` is the strongest first use there is.** The fingerprint the owner
285
+ reads out was worked out in their browser from the private key they hold,
286
+ so a pin made from it never passed through the service -- and a substituted
287
+ key is then refused on the first push, not the second. It and `--forget`
288
+ are local acts: neither makes a request.
289
+
290
+ Plain, it prints the fingerprint of the key the service offers *now*, on
291
+ stdout, and records nothing. Looking is not trusting.
292
+ """
293
+ api = _resolve(args.api, "DATAVALV_API", "--api", "API address").rstrip("/")
294
+ api = api.removesuffix("/v1")
295
+ client_id = _resolve(args.client_id, "DATAVALV_CLIENT_ID", "--client-id", "client id")
296
+ pins = FilePins.default()
297
+ scope = (api, client_id, args.external_id)
298
+
299
+ if args.forget:
300
+ pins.forget(*scope)
301
+ print(
302
+ f"No key is on record for {args.external_id} any more. The next push records "
303
+ "the one it is offered.",
304
+ file=sys.stderr,
305
+ )
306
+ return EXIT_OK
307
+
308
+ if args.trust is not None:
309
+ fingerprint = args.trust.strip().lower().replace(" ", "").replace(":", "")
310
+ if len(fingerprint) != 64 or any(c not in "0123456789abcdef" for c in fingerprint):
311
+ raise ConfigurationError(
312
+ "--trust takes a fingerprint: 64 hexadecimal characters, as shown under "
313
+ "the owner's backups"
314
+ )
315
+ pins.set(*scope, fingerprint)
316
+ print(
317
+ f"{fingerprint} is on record for {args.external_id} in {pins.path}. A push "
318
+ "offered any other key will be refused.",
319
+ file=sys.stderr,
320
+ )
321
+ return EXIT_OK
322
+
323
+ with _client(args, transport) as client:
324
+ offered = crypto.public_key_fingerprint(
325
+ client.tenant_public_key(args.external_id)["publicKeyPem"]
326
+ )
327
+ print(offered)
328
+
329
+ pinned = pins.get(*scope)
330
+ if pinned is None:
331
+ print(
332
+ "That is the key the service offers now. It is not on record here and this "
333
+ "command does not\nrecord it: compare it with the fingerprint under the "
334
+ "owner's backups, then either push, or\nrecord the owner's with --trust.",
335
+ file=sys.stderr,
336
+ )
337
+ return EXIT_OK
338
+ if pinned == offered:
339
+ print("It matches the key on record for this vault.", file=sys.stderr)
340
+ return EXIT_OK
341
+ print(
342
+ f"It is NOT the key on record for this vault, which is\n {pinned}\n"
343
+ "A vault's key never changes. Do not push until you know why this one has.",
344
+ file=sys.stderr,
345
+ )
346
+ return EXIT_KEY_CHANGED
347
+
348
+
349
+ def _read_payload(args: argparse.Namespace) -> tuple[bytes, str | None]:
350
+ """The file's bytes and the name to encrypt beside them.
351
+
352
+ `-` reads stdin, which is what makes `pg_dump … | datavalv push -` work, and
353
+ it carries no name: there is no filename to encrypt and inventing one would
354
+ put a guess in the vault.
355
+ """
356
+ if args.file == "-":
357
+ return sys.stdin.buffer.read(), None
358
+
359
+ path = Path(args.file)
360
+ try:
361
+ data = path.read_bytes()
362
+ except OSError as exc:
363
+ raise ConfigurationError(f"cannot read {path}: {exc.strerror or exc}") from exc
364
+
365
+ if args.no_name:
366
+ return data, None
367
+ # The basename. What directory a source server keeps its dumps in is nobody
368
+ # else's business, and the name itself is encrypted under the backup's own
369
+ # key either way.
370
+ return data, args.name_as or path.name
371
+
372
+
373
+ # ---------------------------------------------------------------- argv
374
+
375
+
376
+ def build_parser() -> argparse.ArgumentParser:
377
+ parser = argparse.ArgumentParser(
378
+ prog="datavalv",
379
+ description="Push end-to-end encrypted backups to Datavalv.",
380
+ epilog=_EXIT_TABLE,
381
+ formatter_class=argparse.RawDescriptionHelpFormatter,
382
+ )
383
+ subcommands = parser.add_subparsers(dest="command", required=True)
384
+
385
+ # The connection settings, attached to the commands that make requests.
386
+ # `keygen` deliberately has none of them: it talks to nothing, and a
387
+ # keypair made on a machine that cannot reach the API is still a keypair.
388
+ connection = argparse.ArgumentParser(add_help=False)
389
+ connection.add_argument("--api", help="API address [$DATAVALV_API]")
390
+ connection.add_argument("--client-id", help="registered client id [$DATAVALV_CLIENT_ID]")
391
+ connection.add_argument("--key-id", help="registered signing key id [$DATAVALV_KEY_ID]")
392
+ connection.add_argument(
393
+ "--private-key",
394
+ help="path to the signing key PEM [$DATAVALV_PRIVATE_KEY]",
395
+ )
396
+
397
+ keygen = subcommands.add_parser(
398
+ "keygen",
399
+ help="make an RSA-3072 signing keypair; print the public half",
400
+ description=(
401
+ "Write an RSA-3072 private key to a file only you can read and print "
402
+ "the public half for pasting into /applications. The private half is "
403
+ "never printed and never sent anywhere."
404
+ ),
405
+ )
406
+ keygen.add_argument(
407
+ "--out", default=DEFAULT_KEY_FILENAME, help=f"where to write it [{DEFAULT_KEY_FILENAME}]"
408
+ )
409
+ keygen.add_argument(
410
+ "--force", action="store_true", help="replace an existing key file"
411
+ )
412
+
413
+ vault = subcommands.add_parser("vault", help="vaults, one per customer")
414
+ vault_commands = vault.add_subparsers(dest="vault_command", required=True)
415
+ vault_create = vault_commands.add_parser(
416
+ "create",
417
+ parents=[connection],
418
+ help="create a vault and email its owner",
419
+ )
420
+ vault_create.add_argument("--external-id", required=True, help="your own id for it")
421
+ vault_create.add_argument(
422
+ "--owner-email", required=True, help="the owner's address; read it back first"
423
+ )
424
+ vault_create.add_argument("--name", help="a name for the vault")
425
+ vault_create.add_argument(
426
+ "--locale",
427
+ choices=("sv", "en"),
428
+ help="what language to write the invitation in [sv]",
429
+ )
430
+ vault_create.add_argument(
431
+ "--retention", type=int, help="how many copies to keep [1]"
432
+ )
433
+
434
+ vault_key = vault_commands.add_parser(
435
+ "key",
436
+ parents=[connection],
437
+ help="show, trust or forget the fingerprint of a vault's key",
438
+ description=(
439
+ "Print the fingerprint of the key the service offers for a vault, and say "
440
+ "whether it is the one on record here. The vault's owner sees the same "
441
+ "fingerprint under their backups, worked out in their browser; compare the "
442
+ "two before the first push, or record theirs with --trust."
443
+ ),
444
+ )
445
+ vault_key.add_argument("--external-id", required=True, help="the vault")
446
+ how = vault_key.add_mutually_exclusive_group()
447
+ how.add_argument(
448
+ "--trust",
449
+ metavar="FINGERPRINT",
450
+ help="record this fingerprint, read out by the vault's owner; makes no request",
451
+ )
452
+ how.add_argument(
453
+ "--forget",
454
+ action="store_true",
455
+ help="drop the key on record, after a vault was deleted and created again",
456
+ )
457
+
458
+ push = subcommands.add_parser(
459
+ "push",
460
+ parents=[connection],
461
+ help="encrypt a file, upload it and confirm it",
462
+ description=(
463
+ "Encrypt FILE against the vault's public key, upload the ciphertext "
464
+ "and confirm it. All four calls or none: a copy counts only once the "
465
+ "last one has returned, and until then the previous copy is untouched."
466
+ ),
467
+ )
468
+ push.add_argument("file", metavar="FILE", help="the file to push, or - for stdin")
469
+ push.add_argument("--external-id", required=True, help="the vault to push to")
470
+ push.add_argument("--label", help="a plaintext note, visible to members")
471
+ push.add_argument(
472
+ "--name-as", help="send this as the (encrypted) filename instead of FILE's"
473
+ )
474
+ push.add_argument(
475
+ "--no-name",
476
+ action="store_true",
477
+ help="send no filename at all, not even encrypted",
478
+ )
479
+ push.add_argument(
480
+ "--idempotency-key",
481
+ help="send Idempotency-Key, so a retried run does not store the night twice",
482
+ )
483
+
484
+ return parser
485
+
486
+
487
+ def _dispatch(args: argparse.Namespace, transport: httpx.BaseTransport | None) -> int:
488
+ if args.command == "keygen":
489
+ return _keygen(args)
490
+ if args.command == "vault":
491
+ if args.vault_command == "key":
492
+ return _vault_key(args, transport)
493
+ return _vault_create(args, transport)
494
+ if args.command == "push":
495
+ return _push(args, transport)
496
+ raise AssertionError(f"unreachable command {args.command!r}") # pragma: no cover
497
+
498
+
499
+ def main(argv: list[str] | None = None, *, transport: httpx.BaseTransport | None = None) -> int:
500
+ """Run one command and answer with its exit code.
501
+
502
+ `transport` is httpx's own injection seam and exists for the tests, which
503
+ drive the real client against a mock transport rather than a live server.
504
+ Nothing on the command line reaches it.
505
+ """
506
+ args = build_parser().parse_args(argv)
507
+
508
+ try:
509
+ return _dispatch(args, transport)
510
+ except ConfigurationError as exc:
511
+ print(f"datavalv: {exc}", file=sys.stderr)
512
+ return EXIT_CONFIG
513
+ except AuthError as exc:
514
+ print(f"datavalv: {exc}", file=sys.stderr)
515
+ return EXIT_AUTH
516
+ except VaultKeyChanged as exc:
517
+ print(f"datavalv: {exc}", file=sys.stderr)
518
+ return EXIT_KEY_CHANGED
519
+ except DatavalvError as exc:
520
+ print(f"datavalv: {exc}", file=sys.stderr)
521
+ return EXIT_RETRY if exc.retryable else _refusal_code(exc)
522
+ except ValueError as exc:
523
+ # A field this client checked before sending: an over-long label, a
524
+ # name that will not fit the encrypted-filename field.
525
+ print(f"datavalv: {exc}", file=sys.stderr)
526
+ return EXIT_FAILED
527
+ except KeyboardInterrupt: # pragma: no cover - needs a signal to provoke
528
+ print("\ndatavalv: interrupted", file=sys.stderr)
529
+ return 130
530
+
531
+
532
+ def _refusal_code(exc: DatavalvError) -> int:
533
+ return EXIT_REFUSED if isinstance(exc, ApiError) else EXIT_FAILED
534
+
535
+
536
+ if __name__ == "__main__": # pragma: no cover
537
+ raise SystemExit(main())