Ensure that `pytest` picks up the newly-split test modules again
Marco Ricci

Marco Ricci commited on 2025-08-09 18:49:50
Zeige 8 geänderte Dateien mit 4829 Einfügungen und 4822 Löschungen.


If a test module is merely converted to a package, then `pytest` will no
longer pick it up, because the name `__init__.py` does not match the
test module name pattern anymore.  So, rename them.

Also set the `pytest` import mode to `importlib`, as per the
documentation's suggestion.  I haven't had any trouble with this *yet*,
but now I have similarly named modules, so this might otherwise crop up
in the future.
... ...
@@ -412,7 +412,7 @@ sqlite_cache = true
412 412
 enable_error_code = ['ignore-without-code']
413 413
 
414 414
 [tool.pytest.ini_options]
415
-addopts = '--doctest-modules --dist=worksteal'
415
+addopts = '--doctest-modules --dist=worksteal --import-mode=importlib'
416 416
 pythonpath = ['src']
417 417
 testpaths = ['src', 'tests']
418 418
 xfail_strict = true
... ...
@@ -1,2978 +1,3 @@
1 1
 # SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2 2
 #
3 3
 # SPDX-License-Identifier: Zlib
4
-
5
-from __future__ import annotations
6
-
7
-import contextlib
8
-import copy
9
-import errno
10
-import json
11
-import os
12
-import pathlib
13
-import shutil
14
-import socket
15
-import textwrap
16
-import types
17
-from typing import TYPE_CHECKING
18
-
19
-import click.testing
20
-import hypothesis
21
-import pytest
22
-from hypothesis import strategies
23
-from typing_extensions import Any, NamedTuple
24
-
25
-from derivepassphrase import _types, cli, ssh_agent, vault
26
-from derivepassphrase._internals import (
27
-    cli_helpers,
28
-    cli_messages,
29
-)
30
-from tests import data, machinery
31
-from tests.data import callables
32
-from tests.machinery import hypothesis as hypothesis_machinery
33
-from tests.machinery import pytest as pytest_machinery
34
-
35
-if TYPE_CHECKING:
36
-    from typing import NoReturn
37
-
38
-    from typing_extensions import Literal
39
-
40
-DUMMY_SERVICE = data.DUMMY_SERVICE
41
-DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
42
-DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
43
-DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
44
-DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
45
-DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW
46
-DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1
47
-
48
-DUMMY_KEY1 = data.DUMMY_KEY1
49
-DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
50
-DUMMY_KEY2 = data.DUMMY_KEY2
51
-DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
52
-DUMMY_KEY3 = data.DUMMY_KEY3
53
-DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64
54
-
55
-TEST_CONFIGS = data.TEST_CONFIGS
56
-
57
-
58
-class IncompatibleConfiguration(NamedTuple):
59
-    other_options: list[tuple[str, ...]]
60
-    needs_service: bool | None
61
-    input: str | None
62
-
63
-
64
-class SingleConfiguration(NamedTuple):
65
-    needs_service: bool | None
66
-    input: str | None
67
-    check_success: bool
68
-
69
-
70
-class OptionCombination(NamedTuple):
71
-    options: list[str]
72
-    incompatible: bool
73
-    needs_service: bool | None
74
-    input: str | None
75
-    check_success: bool
76
-
77
-
78
-PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
79
-    ("--phrase",),
80
-    ("--key",),
81
-    ("--length", "20"),
82
-    ("--repeat", "20"),
83
-    ("--lower", "1"),
84
-    ("--upper", "1"),
85
-    ("--number", "1"),
86
-    ("--space", "1"),
87
-    ("--dash", "1"),
88
-    ("--symbol", "1"),
89
-]
90
-CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
91
-    ("--notes",),
92
-    ("--config",),
93
-    ("--delete",),
94
-    ("--delete-globals",),
95
-    ("--clear",),
96
-]
97
-CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
98
-    ("--delete",),
99
-    ("--delete-globals",),
100
-    ("--clear",),
101
-]
102
-STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")]
103
-INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
104
-    ("--phrase",): IncompatibleConfiguration(
105
-        [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS],
106
-        True,
107
-        DUMMY_PASSPHRASE,
108
-    ),
109
-    ("--key",): IncompatibleConfiguration(
110
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
111
-    ),
112
-    ("--length", "20"): IncompatibleConfiguration(
113
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
114
-    ),
115
-    ("--repeat", "20"): IncompatibleConfiguration(
116
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
117
-    ),
118
-    ("--lower", "1"): IncompatibleConfiguration(
119
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
120
-    ),
121
-    ("--upper", "1"): IncompatibleConfiguration(
122
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
123
-    ),
124
-    ("--number", "1"): IncompatibleConfiguration(
125
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
126
-    ),
127
-    ("--space", "1"): IncompatibleConfiguration(
128
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
129
-    ),
130
-    ("--dash", "1"): IncompatibleConfiguration(
131
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
132
-    ),
133
-    ("--symbol", "1"): IncompatibleConfiguration(
134
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
135
-    ),
136
-    ("--notes",): IncompatibleConfiguration(
137
-        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None
138
-    ),
139
-    ("--config", "-p"): IncompatibleConfiguration(
140
-        [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS],
141
-        None,
142
-        DUMMY_PASSPHRASE,
143
-    ),
144
-    ("--delete",): IncompatibleConfiguration(
145
-        [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None
146
-    ),
147
-    ("--delete-globals",): IncompatibleConfiguration(
148
-        [("--clear",), *STORAGE_OPTIONS], False, None
149
-    ),
150
-    ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
151
-    ("--export", "-"): IncompatibleConfiguration(
152
-        [("--import", "-")], False, None
153
-    ),
154
-    ("--import", "-"): IncompatibleConfiguration([], False, None),
155
-}
156
-SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
157
-    ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
158
-    ("--key",): SingleConfiguration(True, None, False),
159
-    ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
160
-    ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
161
-    ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
162
-    ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
163
-    ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
164
-    ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
165
-    ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
166
-    ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
167
-    ("--notes",): SingleConfiguration(True, None, False),
168
-    ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
169
-    ("--delete",): SingleConfiguration(True, None, False),
170
-    ("--delete-globals",): SingleConfiguration(False, None, True),
171
-    ("--clear",): SingleConfiguration(False, None, True),
172
-    ("--export", "-"): SingleConfiguration(False, None, True),
173
-    ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True),
174
-}
175
-INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
176
-config: IncompatibleConfiguration | SingleConfiguration
177
-for opt, config in INCOMPATIBLE.items():
178
-    for opt2 in config.other_options:
179
-        INTERESTING_OPTION_COMBINATIONS.extend([
180
-            OptionCombination(
181
-                options=list(opt + opt2),
182
-                incompatible=True,
183
-                needs_service=config.needs_service,
184
-                input=config.input,
185
-                check_success=False,
186
-            ),
187
-            OptionCombination(
188
-                options=list(opt2 + opt),
189
-                incompatible=True,
190
-                needs_service=config.needs_service,
191
-                input=config.input,
192
-                check_success=False,
193
-            ),
194
-        ])
195
-for opt, config in SINGLES.items():
196
-    INTERESTING_OPTION_COMBINATIONS.append(
197
-        OptionCombination(
198
-            options=list(opt),
199
-            incompatible=False,
200
-            needs_service=config.needs_service,
201
-            input=config.input,
202
-            check_success=config.check_success,
203
-        )
204
-    )
205
-
206
-
207
-def is_warning_line(line: str) -> bool:
208
-    """Return true if the line is a warning line."""
209
-    return " Warning: " in line or " Deprecation warning: " in line
210
-
211
-
212
-def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
213
-    """Return true if the warning is harmless, during config import."""
214
-    possible_warnings = [
215
-        "Replacing invalid value ",
216
-        "Removing ineffective setting ",
217
-        (
218
-            "Setting a global passphrase is ineffective "
219
-            "because a key is also set."
220
-        ),
221
-        (
222
-            "Setting a service passphrase is ineffective "
223
-            "because a key is also set:"
224
-        ),
225
-    ]
226
-    return any(
227
-        machinery.warning_emitted(w, [record]) for w in possible_warnings
228
-    )
229
-
230
-
231
-def assert_vault_config_is_indented_and_line_broken(
232
-    config_txt: str,
233
-    /,
234
-) -> None:
235
-    """Return true if the vault configuration is indented and line broken.
236
-
237
-    Indented and rewrapped vault configurations as produced by
238
-    `json.dump` contain the closing '}' of the '$.services' object
239
-    on a separate, indented line:
240
-
241
-    ~~~~
242
-    {
243
-      "services": {
244
-        ...
245
-      }  <-- this brace here
246
-    }
247
-    ~~~~
248
-
249
-    or, if there are no services, then the indented line
250
-
251
-    ~~~~
252
-      "services": {}
253
-    ~~~~
254
-
255
-    Both variations may end with a comma if there are more top-level
256
-    keys.
257
-
258
-    """
259
-    known_indented_lines = {
260
-        "}",
261
-        "},",
262
-        '"services": {}',
263
-        '"services": {},',
264
-    }
265
-    assert any([
266
-        line.strip() in known_indented_lines and line.startswith((" ", "\t"))
267
-        for line in config_txt.splitlines()
268
-    ])
269
-
270
-
271
-class Parametrize(types.SimpleNamespace):
272
-    """Common test parametrizations."""
273
-
274
-    CHARSET_NAME = pytest.mark.parametrize(
275
-        "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
276
-    )
277
-    UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize(
278
-        "command_line",
279
-        [
280
-            pytest.param(
281
-                ["--config", "--phrase"],
282
-                id="configure global passphrase",
283
-            ),
284
-            pytest.param(
285
-                ["--config", "--phrase", "--", "DUMMY_SERVICE"],
286
-                id="configure service passphrase",
287
-            ),
288
-            pytest.param(
289
-                ["--phrase", "--", DUMMY_SERVICE],
290
-                id="interactive passphrase",
291
-            ),
292
-        ],
293
-    )
294
-    CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
295
-        ["command_line", "input", "err_text"],
296
-        [
297
-            pytest.param(
298
-                [],
299
-                "",
300
-                "Cannot update the global settings without any given settings",
301
-                id="None",
302
-            ),
303
-            pytest.param(
304
-                ["--", "sv"],
305
-                "",
306
-                "Cannot update the service-specific settings without any given settings",
307
-                id="None-sv",
308
-            ),
309
-            pytest.param(
310
-                ["--phrase", "--", "sv"],
311
-                "\n",
312
-                "No passphrase was given",
313
-                id="phrase-sv",
314
-            ),
315
-            pytest.param(
316
-                ["--phrase", "--", "sv"],
317
-                "",
318
-                "No passphrase was given",
319
-                id="phrase-sv-eof",
320
-            ),
321
-            pytest.param(
322
-                ["--key"],
323
-                "\n",
324
-                "No SSH key was selected",
325
-                id="key-sv",
326
-            ),
327
-            pytest.param(
328
-                ["--key"],
329
-                "",
330
-                "No SSH key was selected",
331
-                id="key-sv-eof",
332
-            ),
333
-        ],
334
-    )
335
-    CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
336
-        ["command_line", "input", "result_config"],
337
-        [
338
-            pytest.param(
339
-                ["--phrase"],
340
-                "my passphrase\n",
341
-                {"global": {"phrase": "my passphrase"}, "services": {}},
342
-                id="phrase",
343
-            ),
344
-            pytest.param(
345
-                ["--key"],
346
-                "1\n",
347
-                {
348
-                    "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"},
349
-                    "services": {},
350
-                },
351
-                id="key",
352
-            ),
353
-            pytest.param(
354
-                ["--phrase", "--", "sv"],
355
-                "my passphrase\n",
356
-                {
357
-                    "global": {"phrase": "abc"},
358
-                    "services": {"sv": {"phrase": "my passphrase"}},
359
-                },
360
-                id="phrase-sv",
361
-            ),
362
-            pytest.param(
363
-                ["--key", "--", "sv"],
364
-                "1\n",
365
-                {
366
-                    "global": {"phrase": "abc"},
367
-                    "services": {"sv": {"key": DUMMY_KEY1_B64}},
368
-                },
369
-                id="key-sv",
370
-            ),
371
-            pytest.param(
372
-                ["--key", "--length", "15", "--", "sv"],
373
-                "1\n",
374
-                {
375
-                    "global": {"phrase": "abc"},
376
-                    "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
377
-                },
378
-                id="key-length-sv",
379
-            ),
380
-        ],
381
-    )
382
-    BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
383
-        "config",
384
-        [
385
-            pytest.param(
386
-                {
387
-                    "global": {"key": DUMMY_KEY1_B64},
388
-                    "services": {DUMMY_SERVICE: {}},
389
-                },
390
-                id="global_config",
391
-            ),
392
-            pytest.param(
393
-                {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}},
394
-                id="service_config",
395
-            ),
396
-            pytest.param(
397
-                {
398
-                    "global": {"key": DUMMY_KEY1_B64},
399
-                    "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}},
400
-                },
401
-                id="full_config",
402
-            ),
403
-        ],
404
-    )
405
-    CONFIG_WITH_KEY = pytest.mark.parametrize(
406
-        "config",
407
-        [
408
-            pytest.param(
409
-                {
410
-                    "global": {"key": DUMMY_KEY1_B64},
411
-                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
412
-                },
413
-                id="global",
414
-            ),
415
-            pytest.param(
416
-                {
417
-                    "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
418
-                    "services": {
419
-                        DUMMY_SERVICE: {
420
-                            "key": DUMMY_KEY1_B64,
421
-                            **DUMMY_CONFIG_SETTINGS,
422
-                        }
423
-                    },
424
-                },
425
-                id="service",
426
-            ),
427
-        ],
428
-    )
429
-    VALID_TEST_CONFIGS = pytest.mark.parametrize(
430
-        "config",
431
-        [conf.config for conf in TEST_CONFIGS if conf.is_valid()],
432
-    )
433
-    KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize(
434
-        ["config", "command_line"],
435
-        [
436
-            pytest.param(
437
-                {
438
-                    "global": {"key": DUMMY_KEY1_B64},
439
-                    "services": {},
440
-                },
441
-                ["--config", "-p"],
442
-                id="global",
443
-            ),
444
-            pytest.param(
445
-                {
446
-                    "services": {
447
-                        DUMMY_SERVICE: {
448
-                            "key": DUMMY_KEY1_B64,
449
-                            **DUMMY_CONFIG_SETTINGS,
450
-                        },
451
-                    },
452
-                },
453
-                ["--config", "-p", "--", DUMMY_SERVICE],
454
-                id="service",
455
-            ),
456
-            pytest.param(
457
-                {
458
-                    "global": {"key": DUMMY_KEY1_B64},
459
-                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()},
460
-                },
461
-                ["--config", "-p", "--", DUMMY_SERVICE],
462
-                id="service-over-global",
463
-            ),
464
-        ],
465
-    )
466
-    NOOP_EDIT_FUNCS = pytest.mark.parametrize(
467
-        ["edit_func_name", "modern_editor_interface"],
468
-        [
469
-            pytest.param("empty", True, id="empty"),
470
-            pytest.param("space", False, id="space-legacy"),
471
-            pytest.param("space", True, id="space-modern"),
472
-        ],
473
-    )
474
-    EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
475
-        "export_options",
476
-        [
477
-            [],
478
-            ["--export-as=sh"],
479
-        ],
480
-    )
481
-    KEY_INDEX = pytest.mark.parametrize(
482
-        "key_index", [1, 2, 3], ids=lambda i: f"index{i}"
483
-    )
484
-    UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
485
-        ["main_config", "command_line", "input", "error_message"],
486
-        [
487
-            pytest.param(
488
-                textwrap.dedent(r"""
489
-                [vault]
490
-                default-unicode-normalization-form = 'XXX'
491
-                """),
492
-                ["--import", "-"],
493
-                json.dumps({
494
-                    "services": {
495
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
496
-                        "with_normalization": {"phrase": "D\u00fcsseldorf"},
497
-                    },
498
-                }),
499
-                (
500
-                    "Invalid value 'XXX' for config key "
501
-                    "vault.default-unicode-normalization-form"
502
-                ),
503
-                id="global",
504
-            ),
505
-            pytest.param(
506
-                textwrap.dedent(r"""
507
-                [vault.unicode-normalization-form]
508
-                with_normalization = 'XXX'
509
-                """),
510
-                ["--import", "-"],
511
-                json.dumps({
512
-                    "services": {
513
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
514
-                        "with_normalization": {"phrase": "D\u00fcsseldorf"},
515
-                    },
516
-                }),
517
-                (
518
-                    "Invalid value 'XXX' for config key "
519
-                    "vault.with_normalization.unicode-normalization-form"
520
-                ),
521
-                id="service",
522
-            ),
523
-        ],
524
-    )
525
-    UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize(
526
-        ["main_config", "command_line", "input", "warning_message"],
527
-        [
528
-            pytest.param(
529
-                "",
530
-                ["--import", "-"],
531
-                json.dumps({
532
-                    "global": {"phrase": "Du\u0308sseldorf"},
533
-                    "services": {},
534
-                }),
535
-                "The $.global passphrase is not NFC-normalized",
536
-                id="global-NFC",
537
-            ),
538
-            pytest.param(
539
-                "",
540
-                ["--import", "-"],
541
-                json.dumps({
542
-                    "services": {
543
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
544
-                        "weird entry name": {"phrase": "Du\u0308sseldorf"},
545
-                    }
546
-                }),
547
-                (
548
-                    'The $.services["weird entry name"] passphrase '
549
-                    "is not NFC-normalized"
550
-                ),
551
-                id="service-weird-name-NFC",
552
-            ),
553
-            pytest.param(
554
-                "",
555
-                ["--config", "-p", "--", DUMMY_SERVICE],
556
-                "Du\u0308sseldorf",
557
-                (
558
-                    f"The $.services.{DUMMY_SERVICE} passphrase "
559
-                    f"is not NFC-normalized"
560
-                ),
561
-                id="config-NFC",
562
-            ),
563
-            pytest.param(
564
-                "",
565
-                ["-p", "--", DUMMY_SERVICE],
566
-                "Du\u0308sseldorf",
567
-                "The interactive input passphrase is not NFC-normalized",
568
-                id="direct-input-NFC",
569
-            ),
570
-            pytest.param(
571
-                textwrap.dedent(r"""
572
-                [vault]
573
-                default-unicode-normalization-form = 'NFD'
574
-                """),
575
-                ["--import", "-"],
576
-                json.dumps({
577
-                    "global": {
578
-                        "phrase": "D\u00fcsseldorf",
579
-                    },
580
-                    "services": {},
581
-                }),
582
-                "The $.global passphrase is not NFD-normalized",
583
-                id="global-NFD",
584
-            ),
585
-            pytest.param(
586
-                textwrap.dedent(r"""
587
-                [vault]
588
-                default-unicode-normalization-form = 'NFD'
589
-                """),
590
-                ["--import", "-"],
591
-                json.dumps({
592
-                    "services": {
593
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
594
-                        "weird entry name": {"phrase": "D\u00fcsseldorf"},
595
-                    },
596
-                }),
597
-                (
598
-                    'The $.services["weird entry name"] passphrase '
599
-                    "is not NFD-normalized"
600
-                ),
601
-                id="service-weird-name-NFD",
602
-            ),
603
-            pytest.param(
604
-                textwrap.dedent(r"""
605
-                [vault.unicode-normalization-form]
606
-                'weird entry name 2' = 'NFKD'
607
-                """),
608
-                ["--import", "-"],
609
-                json.dumps({
610
-                    "services": {
611
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
612
-                        "weird entry name 1": {"phrase": "D\u00fcsseldorf"},
613
-                        "weird entry name 2": {"phrase": "D\u00fcsseldorf"},
614
-                    },
615
-                }),
616
-                (
617
-                    'The $.services["weird entry name 2"] passphrase '
618
-                    "is not NFKD-normalized"
619
-                ),
620
-                id="service-weird-name-2-NFKD",
621
-            ),
622
-        ],
623
-    )
624
-    MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
625
-        "modern_editor_interface", [False, True], ids=["legacy", "modern"]
626
-    )
627
-    NOTES_PLACEMENT = pytest.mark.parametrize(
628
-        ["notes_placement", "placement_args"],
629
-        [
630
-            pytest.param("after", ["--print-notes-after"], id="after"),
631
-            pytest.param("before", ["--print-notes-before"], id="before"),
632
-        ],
633
-    )
634
-    VAULT_CHARSET_OPTION = pytest.mark.parametrize(
635
-        "option",
636
-        [
637
-            "--lower",
638
-            "--upper",
639
-            "--number",
640
-            "--space",
641
-            "--dash",
642
-            "--symbol",
643
-            "--repeat",
644
-            "--length",
645
-        ],
646
-    )
647
-    OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize(
648
-        ["options", "service"],
649
-        [
650
-            pytest.param(o.options, o.needs_service, id=" ".join(o.options))
651
-            for o in INTERESTING_OPTION_COMBINATIONS
652
-            if o.incompatible
653
-        ],
654
-    )
655
-    OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize(
656
-        ["options", "service", "input", "check_success"],
657
-        [
658
-            pytest.param(
659
-                o.options,
660
-                o.needs_service,
661
-                o.input,
662
-                o.check_success,
663
-                id=" ".join(o.options),
664
-            )
665
-            for o in INTERESTING_OPTION_COMBINATIONS
666
-            if not o.incompatible
667
-        ],
668
-    )
669
-    TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
670
-        "try_race_free_implementation", [True, False]
671
-    )
672
-
673
-
674
-class TestCLI:
675
-    """Tests for the `derivepassphrase vault` command-line interface."""
676
-
677
-    def test_200_help_output(
678
-        self,
679
-    ) -> None:
680
-        """The `--help` option emits help text."""
681
-        runner = machinery.CliRunner(mix_stderr=False)
682
-        # TODO(the-13th-letter): Rewrite using parenthesized
683
-        # with-statements.
684
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
685
-        with contextlib.ExitStack() as stack:
686
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
687
-            stack.enter_context(
688
-                pytest_machinery.isolated_config(
689
-                    monkeypatch=monkeypatch,
690
-                    runner=runner,
691
-                )
692
-            )
693
-            result = runner.invoke(
694
-                cli.derivepassphrase_vault,
695
-                ["--help"],
696
-                catch_exceptions=False,
697
-            )
698
-        assert result.clean_exit(
699
-            empty_stderr=True, output="Passphrase generation:\n"
700
-        ), "expected clean exit, and option groups in help text"
701
-        assert result.clean_exit(
702
-            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
703
-        ), "expected clean exit, and option group epilog in help text"
704
-
705
-    # TODO(the-13th-letter): Remove this test once
706
-    # TestAllCLI.test_202_version_option_output no longer xfails.
707
-    def test_200a_version_output(
708
-        self,
709
-    ) -> None:
710
-        """The `--version` option emits version information."""
711
-        runner = machinery.CliRunner(mix_stderr=False)
712
-        # TODO(the-13th-letter): Rewrite using parenthesized
713
-        # with-statements.
714
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
715
-        with contextlib.ExitStack() as stack:
716
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
717
-            stack.enter_context(
718
-                pytest_machinery.isolated_config(
719
-                    monkeypatch=monkeypatch,
720
-                    runner=runner,
721
-                )
722
-            )
723
-            result = runner.invoke(
724
-                cli.derivepassphrase_vault,
725
-                ["--version"],
726
-                catch_exceptions=False,
727
-            )
728
-        assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), (
729
-            "expected clean exit, and program name in version text"
730
-        )
731
-        assert result.clean_exit(empty_stderr=True, output=cli.VERSION), (
732
-            "expected clean exit, and version in help text"
733
-        )
734
-
735
-    @Parametrize.CHARSET_NAME
736
-    def test_201_disable_character_set(
737
-        self,
738
-        charset_name: str,
739
-    ) -> None:
740
-        """Named character classes can be disabled on the command-line."""
741
-        option = f"--{charset_name}"
742
-        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
743
-        runner = machinery.CliRunner(mix_stderr=False)
744
-        # TODO(the-13th-letter): Rewrite using parenthesized
745
-        # with-statements.
746
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
747
-        with contextlib.ExitStack() as stack:
748
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
749
-            stack.enter_context(
750
-                pytest_machinery.isolated_config(
751
-                    monkeypatch=monkeypatch,
752
-                    runner=runner,
753
-                )
754
-            )
755
-            monkeypatch.setattr(
756
-                cli_helpers,
757
-                "prompt_for_passphrase",
758
-                callables.auto_prompt,
759
-            )
760
-            result = runner.invoke(
761
-                cli.derivepassphrase_vault,
762
-                [option, "0", "-p", "--", DUMMY_SERVICE],
763
-                input=DUMMY_PASSPHRASE,
764
-                catch_exceptions=False,
765
-            )
766
-        assert result.clean_exit(empty_stderr=True), "expected clean exit:"
767
-        for c in charset:
768
-            assert c not in result.stdout, (
769
-                f"derived password contains forbidden character {c!r}"
770
-            )
771
-
772
-    def test_202_disable_repetition(
773
-        self,
774
-    ) -> None:
775
-        """Character repetition can be disabled on the command-line."""
776
-        runner = machinery.CliRunner(mix_stderr=False)
777
-        # TODO(the-13th-letter): Rewrite using parenthesized
778
-        # with-statements.
779
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
780
-        with contextlib.ExitStack() as stack:
781
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
782
-            stack.enter_context(
783
-                pytest_machinery.isolated_config(
784
-                    monkeypatch=monkeypatch,
785
-                    runner=runner,
786
-                )
787
-            )
788
-            monkeypatch.setattr(
789
-                cli_helpers,
790
-                "prompt_for_passphrase",
791
-                callables.auto_prompt,
792
-            )
793
-            result = runner.invoke(
794
-                cli.derivepassphrase_vault,
795
-                ["--repeat", "0", "-p", "--", DUMMY_SERVICE],
796
-                input=DUMMY_PASSPHRASE,
797
-                catch_exceptions=False,
798
-            )
799
-        assert result.clean_exit(empty_stderr=True), (
800
-            "expected clean exit and empty stderr"
801
-        )
802
-        passphrase = result.stdout.rstrip("\r\n")
803
-        for i in range(len(passphrase) - 1):
804
-            assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
805
-                f"derived password contains repeated character "
806
-                f"at position {i}: {result.stdout!r}"
807
-            )
808
-
809
-    @Parametrize.CONFIG_WITH_KEY
810
-    def test_204a_key_from_config(
811
-        self,
812
-        running_ssh_agent: data.RunningSSHAgentInfo,
813
-        config: _types.VaultConfig,
814
-    ) -> None:
815
-        """A stored configured SSH key will be used."""
816
-        del running_ssh_agent
817
-        runner = machinery.CliRunner(mix_stderr=False)
818
-        # TODO(the-13th-letter): Rewrite using parenthesized
819
-        # with-statements.
820
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
821
-        with contextlib.ExitStack() as stack:
822
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
823
-            stack.enter_context(
824
-                pytest_machinery.isolated_vault_config(
825
-                    monkeypatch=monkeypatch,
826
-                    runner=runner,
827
-                    vault_config=config,
828
-                )
829
-            )
830
-            monkeypatch.setattr(
831
-                vault.Vault,
832
-                "phrase_from_key",
833
-                callables.phrase_from_key,
834
-            )
835
-            result = runner.invoke(
836
-                cli.derivepassphrase_vault,
837
-                ["--", DUMMY_SERVICE],
838
-                catch_exceptions=False,
839
-            )
840
-        assert result.clean_exit(empty_stderr=True), (
841
-            "expected clean exit and empty stderr"
842
-        )
843
-        assert result.stdout
844
-        assert (
845
-            result.stdout.rstrip("\n").encode("UTF-8")
846
-            != DUMMY_RESULT_PASSPHRASE
847
-        ), "known false output: phrase-based instead of key-based"
848
-        assert (
849
-            result.stdout.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1
850
-        ), "expected known output"
851
-
852
-    def test_204b_key_from_command_line(
853
-        self,
854
-        running_ssh_agent: data.RunningSSHAgentInfo,
855
-    ) -> None:
856
-        """An SSH key requested on the command-line will be used."""
857
-        del running_ssh_agent
858
-        runner = machinery.CliRunner(mix_stderr=False)
859
-        # TODO(the-13th-letter): Rewrite using parenthesized
860
-        # with-statements.
861
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
862
-        with contextlib.ExitStack() as stack:
863
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
864
-            stack.enter_context(
865
-                pytest_machinery.isolated_vault_config(
866
-                    monkeypatch=monkeypatch,
867
-                    runner=runner,
868
-                    vault_config={
869
-                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
870
-                    },
871
-                )
872
-            )
873
-            monkeypatch.setattr(
874
-                cli_helpers,
875
-                "get_suitable_ssh_keys",
876
-                callables.suitable_ssh_keys,
877
-            )
878
-            monkeypatch.setattr(
879
-                vault.Vault,
880
-                "phrase_from_key",
881
-                callables.phrase_from_key,
882
-            )
883
-            result = runner.invoke(
884
-                cli.derivepassphrase_vault,
885
-                ["-k", "--", DUMMY_SERVICE],
886
-                input="1\n",
887
-                catch_exceptions=False,
888
-            )
889
-        assert result.clean_exit(), "expected clean exit"
890
-        assert result.stdout, "expected program output"
891
-        last_line = result.stdout.splitlines(True)[-1]
892
-        assert (
893
-            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
894
-        ), "known false output: phrase-based instead of key-based"
895
-        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
896
-            "expected known output"
897
-        )
898
-
899
-    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
900
-    @Parametrize.KEY_INDEX
901
-    def test_204c_key_override_on_command_line(
902
-        self,
903
-        running_ssh_agent: data.RunningSSHAgentInfo,
904
-        config: dict[str, Any],
905
-        key_index: int,
906
-    ) -> None:
907
-        """A command-line SSH key will override the configured key."""
908
-        del running_ssh_agent
909
-        runner = machinery.CliRunner(mix_stderr=False)
910
-        # TODO(the-13th-letter): Rewrite using parenthesized
911
-        # with-statements.
912
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
913
-        with contextlib.ExitStack() as stack:
914
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
915
-            stack.enter_context(
916
-                pytest_machinery.isolated_vault_config(
917
-                    monkeypatch=monkeypatch,
918
-                    runner=runner,
919
-                    vault_config=config,
920
-                )
921
-            )
922
-            monkeypatch.setattr(
923
-                ssh_agent.SSHAgentClient,
924
-                "list_keys",
925
-                callables.list_keys,
926
-            )
927
-            monkeypatch.setattr(
928
-                ssh_agent.SSHAgentClient, "sign", callables.sign
929
-            )
930
-            result = runner.invoke(
931
-                cli.derivepassphrase_vault,
932
-                ["-k", "--", DUMMY_SERVICE],
933
-                input=f"{key_index}\n",
934
-            )
935
-        assert result.clean_exit(), "expected clean exit"
936
-        assert result.stdout, "expected program output"
937
-        assert result.stderr, "expected stderr"
938
-        assert "Error:" not in result.stderr, (
939
-            "expected no error messages on stderr"
940
-        )
941
-
942
-    def test_205_service_phrase_if_key_in_global_config(
943
-        self,
944
-        running_ssh_agent: data.RunningSSHAgentInfo,
945
-    ) -> None:
946
-        """A command-line passphrase will override the configured key."""
947
-        del running_ssh_agent
948
-        runner = machinery.CliRunner(mix_stderr=False)
949
-        # TODO(the-13th-letter): Rewrite using parenthesized
950
-        # with-statements.
951
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
952
-        with contextlib.ExitStack() as stack:
953
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
954
-            stack.enter_context(
955
-                pytest_machinery.isolated_vault_config(
956
-                    monkeypatch=monkeypatch,
957
-                    runner=runner,
958
-                    vault_config={
959
-                        "global": {"key": DUMMY_KEY1_B64},
960
-                        "services": {
961
-                            DUMMY_SERVICE: {
962
-                                "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
963
-                                **DUMMY_CONFIG_SETTINGS,
964
-                            }
965
-                        },
966
-                    },
967
-                )
968
-            )
969
-            monkeypatch.setattr(
970
-                ssh_agent.SSHAgentClient,
971
-                "list_keys",
972
-                callables.list_keys,
973
-            )
974
-            monkeypatch.setattr(
975
-                ssh_agent.SSHAgentClient, "sign", callables.sign
976
-            )
977
-            result = runner.invoke(
978
-                cli.derivepassphrase_vault,
979
-                ["--", DUMMY_SERVICE],
980
-                catch_exceptions=False,
981
-            )
982
-        assert result.clean_exit(), "expected clean exit"
983
-        assert result.stdout, "expected program output"
984
-        last_line = result.stdout.splitlines(True)[-1]
985
-        assert (
986
-            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
987
-        ), "known false output: phrase-based instead of key-based"
988
-        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
989
-            "expected known output"
990
-        )
991
-
992
-    @Parametrize.KEY_OVERRIDING_IN_CONFIG
993
-    def test_206_setting_phrase_thus_overriding_key_in_config(
994
-        self,
995
-        running_ssh_agent: data.RunningSSHAgentInfo,
996
-        caplog: pytest.LogCaptureFixture,
997
-        config: _types.VaultConfig,
998
-        command_line: list[str],
999
-    ) -> None:
1000
-        """Configuring a passphrase atop an SSH key works, but warns."""
1001
-        del running_ssh_agent
1002
-        runner = machinery.CliRunner(mix_stderr=False)
1003
-        # TODO(the-13th-letter): Rewrite using parenthesized
1004
-        # with-statements.
1005
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1006
-        with contextlib.ExitStack() as stack:
1007
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1008
-            stack.enter_context(
1009
-                pytest_machinery.isolated_vault_config(
1010
-                    monkeypatch=monkeypatch,
1011
-                    runner=runner,
1012
-                    vault_config=config,
1013
-                )
1014
-            )
1015
-            monkeypatch.setattr(
1016
-                ssh_agent.SSHAgentClient,
1017
-                "list_keys",
1018
-                callables.list_keys,
1019
-            )
1020
-            monkeypatch.setattr(
1021
-                ssh_agent.SSHAgentClient, "sign", callables.sign
1022
-            )
1023
-            result = runner.invoke(
1024
-                cli.derivepassphrase_vault,
1025
-                command_line,
1026
-                input=DUMMY_PASSPHRASE,
1027
-                catch_exceptions=False,
1028
-            )
1029
-        assert result.clean_exit(), "expected clean exit"
1030
-        assert not result.stdout.strip(), "expected no program output"
1031
-        assert result.stderr, "expected known error output"
1032
-        err_lines = result.stderr.splitlines(False)
1033
-        assert err_lines[0].startswith("Passphrase:")
1034
-        assert machinery.warning_emitted(
1035
-            "Setting a service passphrase is ineffective ",
1036
-            caplog.record_tuples,
1037
-        ) or machinery.warning_emitted(
1038
-            "Setting a global passphrase is ineffective ",
1039
-            caplog.record_tuples,
1040
-        ), "expected known warning message"
1041
-        assert all(map(is_warning_line, result.stderr.splitlines(True)))
1042
-        assert all(
1043
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1044
-        ), "unexpected error output"
1045
-
1046
-    @hypothesis.given(
1047
-        notes=strategies.text(
1048
-            strategies.characters(
1049
-                min_codepoint=32,
1050
-                max_codepoint=126,
1051
-                include_characters="\n",
1052
-            ),
1053
-            max_size=256,
1054
-        ),
1055
-    )
1056
-    def test_207_service_with_notes_actually_prints_notes(
1057
-        self,
1058
-        notes: str,
1059
-    ) -> None:
1060
-        """Service notes are printed, if they exist."""
1061
-        hypothesis.assume("Error:" not in notes)
1062
-        runner = machinery.CliRunner(mix_stderr=False)
1063
-        # TODO(the-13th-letter): Rewrite using parenthesized
1064
-        # with-statements.
1065
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1066
-        with contextlib.ExitStack() as stack:
1067
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1068
-            stack.enter_context(
1069
-                pytest_machinery.isolated_vault_config(
1070
-                    monkeypatch=monkeypatch,
1071
-                    runner=runner,
1072
-                    vault_config={
1073
-                        "global": {
1074
-                            "phrase": DUMMY_PASSPHRASE,
1075
-                        },
1076
-                        "services": {
1077
-                            DUMMY_SERVICE: {
1078
-                                "notes": notes,
1079
-                                **DUMMY_CONFIG_SETTINGS,
1080
-                            },
1081
-                        },
1082
-                    },
1083
-                )
1084
-            )
1085
-            result = runner.invoke(
1086
-                cli.derivepassphrase_vault,
1087
-                ["--", DUMMY_SERVICE],
1088
-            )
1089
-        assert result.clean_exit(), "expected clean exit"
1090
-        assert result.stdout, "expected program output"
1091
-        assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode(
1092
-            "ascii"
1093
-        ), "expected known program output"
1094
-        assert result.stderr or not notes.strip(), "expected stderr"
1095
-        assert "Error:" not in result.stderr, (
1096
-            "expected no error messages on stderr"
1097
-        )
1098
-        assert result.stderr.strip() == notes.strip(), (
1099
-            "expected known stderr contents"
1100
-        )
1101
-
1102
-    @Parametrize.VAULT_CHARSET_OPTION
1103
-    def test_210_invalid_argument_range(
1104
-        self,
1105
-        option: str,
1106
-    ) -> None:
1107
-        """Requesting invalidly many characters from a class fails."""
1108
-        runner = machinery.CliRunner(mix_stderr=False)
1109
-        # TODO(the-13th-letter): Rewrite using parenthesized
1110
-        # with-statements.
1111
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1112
-        with contextlib.ExitStack() as stack:
1113
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1114
-            stack.enter_context(
1115
-                pytest_machinery.isolated_config(
1116
-                    monkeypatch=monkeypatch,
1117
-                    runner=runner,
1118
-                )
1119
-            )
1120
-            for value in "-42", "invalid":
1121
-                result = runner.invoke(
1122
-                    cli.derivepassphrase_vault,
1123
-                    [option, value, "-p", "--", DUMMY_SERVICE],
1124
-                    input=DUMMY_PASSPHRASE,
1125
-                    catch_exceptions=False,
1126
-                )
1127
-                assert result.error_exit(error="Invalid value"), (
1128
-                    "expected error exit and known error message"
1129
-                )
1130
-
1131
-    @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
1132
-    def test_211_service_needed(
1133
-        self,
1134
-        options: list[str],
1135
-        service: bool | None,
1136
-        input: str | None,
1137
-        check_success: bool,
1138
-    ) -> None:
1139
-        """We require or forbid a service argument, depending on options."""
1140
-        runner = machinery.CliRunner(mix_stderr=False)
1141
-        # TODO(the-13th-letter): Rewrite using parenthesized
1142
-        # with-statements.
1143
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1144
-        with contextlib.ExitStack() as stack:
1145
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1146
-            stack.enter_context(
1147
-                pytest_machinery.isolated_vault_config(
1148
-                    monkeypatch=monkeypatch,
1149
-                    runner=runner,
1150
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
1151
-                )
1152
-            )
1153
-            monkeypatch.setattr(
1154
-                cli_helpers,
1155
-                "prompt_for_passphrase",
1156
-                callables.auto_prompt,
1157
-            )
1158
-            result = runner.invoke(
1159
-                cli.derivepassphrase_vault,
1160
-                options if service else [*options, "--", DUMMY_SERVICE],
1161
-                input=input,
1162
-                catch_exceptions=False,
1163
-            )
1164
-            if service is not None:
1165
-                err_msg = (
1166
-                    " requires a SERVICE"
1167
-                    if service
1168
-                    else " does not take a SERVICE argument"
1169
-                )
1170
-                assert result.error_exit(error=err_msg), (
1171
-                    "expected error exit and known error message"
1172
-                )
1173
-            else:
1174
-                assert result.clean_exit(empty_stderr=True), (
1175
-                    "expected clean exit"
1176
-                )
1177
-        if check_success:
1178
-            # TODO(the-13th-letter): Rewrite using parenthesized
1179
-            # with-statements.
1180
-            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1181
-            with contextlib.ExitStack() as stack:
1182
-                monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1183
-                stack.enter_context(
1184
-                    pytest_machinery.isolated_vault_config(
1185
-                        monkeypatch=monkeypatch,
1186
-                        runner=runner,
1187
-                        vault_config={
1188
-                            "global": {"phrase": "abc"},
1189
-                            "services": {},
1190
-                        },
1191
-                    )
1192
-                )
1193
-                monkeypatch.setattr(
1194
-                    cli_helpers,
1195
-                    "prompt_for_passphrase",
1196
-                    callables.auto_prompt,
1197
-                )
1198
-                result = runner.invoke(
1199
-                    cli.derivepassphrase_vault,
1200
-                    [*options, "--", DUMMY_SERVICE] if service else options,
1201
-                    input=input,
1202
-                    catch_exceptions=False,
1203
-                )
1204
-            assert result.clean_exit(empty_stderr=True), "expected clean exit"
1205
-
1206
-    def test_211a_empty_service_name_causes_warning(
1207
-        self,
1208
-        caplog: pytest.LogCaptureFixture,
1209
-    ) -> None:
1210
-        """Using an empty service name (where permissible) warns.
1211
-
1212
-        Only the `--config` option can optionally take a service name.
1213
-
1214
-        """
1215
-
1216
-        def is_expected_warning(record: tuple[str, int, str]) -> bool:
1217
-            return is_harmless_config_import_warning(
1218
-                record
1219
-            ) or machinery.warning_emitted(
1220
-                "An empty SERVICE is not supported by vault(1)", [record]
1221
-            )
1222
-
1223
-        runner = machinery.CliRunner(mix_stderr=False)
1224
-        # TODO(the-13th-letter): Rewrite using parenthesized
1225
-        # with-statements.
1226
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1227
-        with contextlib.ExitStack() as stack:
1228
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1229
-            stack.enter_context(
1230
-                pytest_machinery.isolated_vault_config(
1231
-                    monkeypatch=monkeypatch,
1232
-                    runner=runner,
1233
-                    vault_config={"services": {}},
1234
-                )
1235
-            )
1236
-            monkeypatch.setattr(
1237
-                cli_helpers,
1238
-                "prompt_for_passphrase",
1239
-                callables.auto_prompt,
1240
-            )
1241
-            result = runner.invoke(
1242
-                cli.derivepassphrase_vault,
1243
-                ["--config", "--length=30", "--", ""],
1244
-                catch_exceptions=False,
1245
-            )
1246
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1247
-            assert result.stderr is not None, "expected known error output"
1248
-            assert all(map(is_expected_warning, caplog.record_tuples)), (
1249
-                "expected known error output"
1250
-            )
1251
-            assert cli_helpers.load_config() == {
1252
-                "global": {"length": 30},
1253
-                "services": {},
1254
-            }, "requested configuration change was not applied"
1255
-            caplog.clear()
1256
-            result = runner.invoke(
1257
-                cli.derivepassphrase_vault,
1258
-                ["--import", "-"],
1259
-                input=json.dumps({"services": {"": {"length": 40}}}),
1260
-                catch_exceptions=False,
1261
-            )
1262
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1263
-            assert result.stderr is not None, "expected known error output"
1264
-            assert all(map(is_expected_warning, caplog.record_tuples)), (
1265
-                "expected known error output"
1266
-            )
1267
-            assert cli_helpers.load_config() == {
1268
-                "global": {"length": 30},
1269
-                "services": {"": {"length": 40}},
1270
-            }, "requested configuration change was not applied"
1271
-
1272
-    @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
1273
-    def test_212_incompatible_options(
1274
-        self,
1275
-        options: list[str],
1276
-        service: bool | None,
1277
-    ) -> None:
1278
-        """Incompatible options are detected."""
1279
-        runner = machinery.CliRunner(mix_stderr=False)
1280
-        # TODO(the-13th-letter): Rewrite using parenthesized
1281
-        # with-statements.
1282
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1283
-        with contextlib.ExitStack() as stack:
1284
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1285
-            stack.enter_context(
1286
-                pytest_machinery.isolated_config(
1287
-                    monkeypatch=monkeypatch,
1288
-                    runner=runner,
1289
-                )
1290
-            )
1291
-            result = runner.invoke(
1292
-                cli.derivepassphrase_vault,
1293
-                [*options, "--", DUMMY_SERVICE] if service else options,
1294
-                input=DUMMY_PASSPHRASE,
1295
-                catch_exceptions=False,
1296
-            )
1297
-        assert result.error_exit(error="mutually exclusive with "), (
1298
-            "expected error exit and known error message"
1299
-        )
1300
-
1301
-    @Parametrize.VALID_TEST_CONFIGS
1302
-    def test_213_import_config_success(
1303
-        self,
1304
-        caplog: pytest.LogCaptureFixture,
1305
-        config: Any,
1306
-    ) -> None:
1307
-        """Importing a configuration works."""
1308
-        runner = machinery.CliRunner(mix_stderr=False)
1309
-        # TODO(the-13th-letter): Rewrite using parenthesized
1310
-        # with-statements.
1311
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1312
-        with contextlib.ExitStack() as stack:
1313
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1314
-            stack.enter_context(
1315
-                pytest_machinery.isolated_vault_config(
1316
-                    monkeypatch=monkeypatch,
1317
-                    runner=runner,
1318
-                    vault_config={"services": {}},
1319
-                )
1320
-            )
1321
-            result = runner.invoke(
1322
-                cli.derivepassphrase_vault,
1323
-                ["--import", "-"],
1324
-                input=json.dumps(config),
1325
-                catch_exceptions=False,
1326
-            )
1327
-            config_txt = cli_helpers.config_filename(
1328
-                subsystem="vault"
1329
-            ).read_text(encoding="UTF-8")
1330
-            config2 = json.loads(config_txt)
1331
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1332
-        assert config2 == config, "config not imported correctly"
1333
-        assert not result.stderr or all(  # pragma: no branch
1334
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1335
-        ), "unexpected error output"
1336
-        assert_vault_config_is_indented_and_line_broken(config_txt)
1337
-
1338
-    @hypothesis.settings(
1339
-        suppress_health_check=[
1340
-            *hypothesis.settings().suppress_health_check,
1341
-            hypothesis.HealthCheck.function_scoped_fixture,
1342
-        ],
1343
-    )
1344
-    @hypothesis.given(
1345
-        conf=hypothesis_machinery.smudged_vault_test_config(
1346
-            strategies.sampled_from([
1347
-                conf for conf in data.TEST_CONFIGS if conf.is_valid()
1348
-            ])
1349
-        )
1350
-    )
1351
-    def test_213a_import_config_success(
1352
-        self,
1353
-        caplog: pytest.LogCaptureFixture,
1354
-        conf: data.VaultTestConfig,
1355
-    ) -> None:
1356
-        """Importing a smudged configuration works.
1357
-
1358
-        Tested via hypothesis.
1359
-
1360
-        """
1361
-        config = conf.config
1362
-        config2 = copy.deepcopy(config)
1363
-        _types.clean_up_falsy_vault_config_values(config2)
1364
-        # Reset caplog between hypothesis runs.
1365
-        caplog.clear()
1366
-        runner = machinery.CliRunner(mix_stderr=False)
1367
-        # TODO(the-13th-letter): Rewrite using parenthesized
1368
-        # with-statements.
1369
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1370
-        with contextlib.ExitStack() as stack:
1371
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1372
-            stack.enter_context(
1373
-                pytest_machinery.isolated_vault_config(
1374
-                    monkeypatch=monkeypatch,
1375
-                    runner=runner,
1376
-                    vault_config={"services": {}},
1377
-                )
1378
-            )
1379
-            result = runner.invoke(
1380
-                cli.derivepassphrase_vault,
1381
-                ["--import", "-"],
1382
-                input=json.dumps(config),
1383
-                catch_exceptions=False,
1384
-            )
1385
-            config_txt = cli_helpers.config_filename(
1386
-                subsystem="vault"
1387
-            ).read_text(encoding="UTF-8")
1388
-            config3 = json.loads(config_txt)
1389
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1390
-        assert config3 == config2, "config not imported correctly"
1391
-        assert not result.stderr or all(
1392
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1393
-        ), "unexpected error output"
1394
-        assert_vault_config_is_indented_and_line_broken(config_txt)
1395
-
1396
-    def test_213b_import_bad_config_not_vault_config(
1397
-        self,
1398
-    ) -> None:
1399
-        """Importing an invalid config fails."""
1400
-        runner = machinery.CliRunner(mix_stderr=False)
1401
-        # TODO(the-13th-letter): Rewrite using parenthesized
1402
-        # with-statements.
1403
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1404
-        with contextlib.ExitStack() as stack:
1405
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1406
-            stack.enter_context(
1407
-                pytest_machinery.isolated_config(
1408
-                    monkeypatch=monkeypatch,
1409
-                    runner=runner,
1410
-                )
1411
-            )
1412
-            result = runner.invoke(
1413
-                cli.derivepassphrase_vault,
1414
-                ["--import", "-"],
1415
-                input="null",
1416
-                catch_exceptions=False,
1417
-            )
1418
-        assert result.error_exit(error="Invalid vault config"), (
1419
-            "expected error exit and known error message"
1420
-        )
1421
-
1422
-    def test_213c_import_bad_config_not_json_data(
1423
-        self,
1424
-    ) -> None:
1425
-        """Importing an invalid config fails."""
1426
-        runner = machinery.CliRunner(mix_stderr=False)
1427
-        # TODO(the-13th-letter): Rewrite using parenthesized
1428
-        # with-statements.
1429
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1430
-        with contextlib.ExitStack() as stack:
1431
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1432
-            stack.enter_context(
1433
-                pytest_machinery.isolated_config(
1434
-                    monkeypatch=monkeypatch,
1435
-                    runner=runner,
1436
-                )
1437
-            )
1438
-            result = runner.invoke(
1439
-                cli.derivepassphrase_vault,
1440
-                ["--import", "-"],
1441
-                input="This string is not valid JSON.",
1442
-                catch_exceptions=False,
1443
-            )
1444
-        assert result.error_exit(error="cannot decode JSON"), (
1445
-            "expected error exit and known error message"
1446
-        )
1447
-
1448
-    def test_213d_import_bad_config_not_a_file(
1449
-        self,
1450
-    ) -> None:
1451
-        """Importing an invalid config fails."""
1452
-        runner = machinery.CliRunner(mix_stderr=False)
1453
-        # `isolated_vault_config` ensures the configuration is valid
1454
-        # JSON.  So, to pass an actual broken configuration, we must
1455
-        # open the configuration file ourselves afterwards, inside the
1456
-        # context.
1457
-        #
1458
-        # TODO(the-13th-letter): Rewrite using parenthesized
1459
-        # with-statements.
1460
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1461
-        with contextlib.ExitStack() as stack:
1462
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1463
-            stack.enter_context(
1464
-                pytest_machinery.isolated_vault_config(
1465
-                    monkeypatch=monkeypatch,
1466
-                    runner=runner,
1467
-                    vault_config={"services": {}},
1468
-                )
1469
-            )
1470
-            cli_helpers.config_filename(subsystem="vault").write_text(
1471
-                "This string is not valid JSON.\n", encoding="UTF-8"
1472
-            )
1473
-            dname = cli_helpers.config_filename(subsystem=None)
1474
-            result = runner.invoke(
1475
-                cli.derivepassphrase_vault,
1476
-                ["--import", os.fsdecode(dname)],
1477
-                catch_exceptions=False,
1478
-            )
1479
-        # The Annoying OS uses EACCES, other OSes use EISDIR.
1480
-        assert result.error_exit(
1481
-            error=os.strerror(errno.EISDIR)
1482
-        ) or result.error_exit(error=os.strerror(errno.EACCES)), (
1483
-            "expected error exit and known error message"
1484
-        )
1485
-
1486
-    @Parametrize.VALID_TEST_CONFIGS
1487
-    def test_214_export_config_success(
1488
-        self,
1489
-        caplog: pytest.LogCaptureFixture,
1490
-        config: Any,
1491
-    ) -> None:
1492
-        """Exporting a configuration works."""
1493
-        runner = machinery.CliRunner(mix_stderr=False)
1494
-        # TODO(the-13th-letter): Rewrite using parenthesized
1495
-        # with-statements.
1496
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1497
-        with contextlib.ExitStack() as stack:
1498
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1499
-            stack.enter_context(
1500
-                pytest_machinery.isolated_vault_config(
1501
-                    monkeypatch=monkeypatch,
1502
-                    runner=runner,
1503
-                    vault_config=config,
1504
-                )
1505
-            )
1506
-            with cli_helpers.config_filename(subsystem="vault").open(
1507
-                "w", encoding="UTF-8"
1508
-            ) as outfile:
1509
-                # Ensure the config is written on one line.
1510
-                json.dump(config, outfile, indent=None)
1511
-            result = runner.invoke(
1512
-                cli.derivepassphrase_vault,
1513
-                ["--export", "-"],
1514
-                catch_exceptions=False,
1515
-            )
1516
-            with cli_helpers.config_filename(subsystem="vault").open(
1517
-                encoding="UTF-8"
1518
-            ) as infile:
1519
-                config2 = json.load(infile)
1520
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1521
-        assert config2 == config, "config not imported correctly"
1522
-        assert not result.stderr or all(  # pragma: no branch
1523
-            map(is_harmless_config_import_warning, caplog.record_tuples)
1524
-        ), "unexpected error output"
1525
-        assert_vault_config_is_indented_and_line_broken(result.stdout)
1526
-
1527
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1528
-    def test_214a_export_settings_no_stored_settings(
1529
-        self,
1530
-        export_options: list[str],
1531
-    ) -> None:
1532
-        """Exporting the default, empty config works."""
1533
-        runner = machinery.CliRunner(mix_stderr=False)
1534
-        # TODO(the-13th-letter): Rewrite using parenthesized
1535
-        # with-statements.
1536
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1537
-        with contextlib.ExitStack() as stack:
1538
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1539
-            stack.enter_context(
1540
-                pytest_machinery.isolated_config(
1541
-                    monkeypatch=monkeypatch,
1542
-                    runner=runner,
1543
-                )
1544
-            )
1545
-            cli_helpers.config_filename(subsystem="vault").unlink(
1546
-                missing_ok=True
1547
-            )
1548
-            result = runner.invoke(
1549
-                # Test parent context navigation by not calling
1550
-                # `cli.derivepassphrase_vault` directly.  Used e.g. in
1551
-                # the `--export-as=sh` section to autoconstruct the
1552
-                # program name correctly.
1553
-                cli.derivepassphrase,
1554
-                ["vault", "--export", "-", *export_options],
1555
-                catch_exceptions=False,
1556
-            )
1557
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
1558
-
1559
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1560
-    def test_214b_export_settings_bad_stored_config(
1561
-        self,
1562
-        export_options: list[str],
1563
-    ) -> None:
1564
-        """Exporting an invalid config fails."""
1565
-        runner = machinery.CliRunner(mix_stderr=False)
1566
-        # TODO(the-13th-letter): Rewrite using parenthesized
1567
-        # with-statements.
1568
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1569
-        with contextlib.ExitStack() as stack:
1570
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1571
-            stack.enter_context(
1572
-                pytest_machinery.isolated_vault_config(
1573
-                    monkeypatch=monkeypatch,
1574
-                    runner=runner,
1575
-                    vault_config={},
1576
-                )
1577
-            )
1578
-            result = runner.invoke(
1579
-                cli.derivepassphrase_vault,
1580
-                ["--export", "-", *export_options],
1581
-                input="null",
1582
-                catch_exceptions=False,
1583
-            )
1584
-        assert result.error_exit(error="Cannot load vault settings:"), (
1585
-            "expected error exit and known error message"
1586
-        )
1587
-
1588
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1589
-    def test_214c_export_settings_not_a_file(
1590
-        self,
1591
-        export_options: list[str],
1592
-    ) -> None:
1593
-        """Exporting an invalid config fails."""
1594
-        runner = machinery.CliRunner(mix_stderr=False)
1595
-        # TODO(the-13th-letter): Rewrite using parenthesized
1596
-        # with-statements.
1597
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1598
-        with contextlib.ExitStack() as stack:
1599
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1600
-            stack.enter_context(
1601
-                pytest_machinery.isolated_config(
1602
-                    monkeypatch=monkeypatch,
1603
-                    runner=runner,
1604
-                )
1605
-            )
1606
-            config_file = cli_helpers.config_filename(subsystem="vault")
1607
-            config_file.unlink(missing_ok=True)
1608
-            config_file.mkdir(parents=True, exist_ok=True)
1609
-            result = runner.invoke(
1610
-                cli.derivepassphrase_vault,
1611
-                ["--export", "-", *export_options],
1612
-                input="null",
1613
-                catch_exceptions=False,
1614
-            )
1615
-        assert result.error_exit(error="Cannot load vault settings:"), (
1616
-            "expected error exit and known error message"
1617
-        )
1618
-
1619
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1620
-    def test_214d_export_settings_target_not_a_file(
1621
-        self,
1622
-        export_options: list[str],
1623
-    ) -> None:
1624
-        """Exporting an invalid config fails."""
1625
-        runner = machinery.CliRunner(mix_stderr=False)
1626
-        # TODO(the-13th-letter): Rewrite using parenthesized
1627
-        # with-statements.
1628
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1629
-        with contextlib.ExitStack() as stack:
1630
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1631
-            stack.enter_context(
1632
-                pytest_machinery.isolated_config(
1633
-                    monkeypatch=monkeypatch,
1634
-                    runner=runner,
1635
-                )
1636
-            )
1637
-            dname = cli_helpers.config_filename(subsystem=None)
1638
-            result = runner.invoke(
1639
-                cli.derivepassphrase_vault,
1640
-                ["--export", os.fsdecode(dname), *export_options],
1641
-                input="null",
1642
-                catch_exceptions=False,
1643
-            )
1644
-        assert result.error_exit(error="Cannot export vault settings:"), (
1645
-            "expected error exit and known error message"
1646
-        )
1647
-
1648
-    @pytest_machinery.skip_if_on_the_annoying_os
1649
-    @Parametrize.EXPORT_FORMAT_OPTIONS
1650
-    def test_214e_export_settings_settings_directory_not_a_directory(
1651
-        self,
1652
-        export_options: list[str],
1653
-    ) -> None:
1654
-        """Exporting an invalid config fails."""
1655
-        runner = machinery.CliRunner(mix_stderr=False)
1656
-        # TODO(the-13th-letter): Rewrite using parenthesized
1657
-        # with-statements.
1658
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1659
-        with contextlib.ExitStack() as stack:
1660
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1661
-            stack.enter_context(
1662
-                pytest_machinery.isolated_config(
1663
-                    monkeypatch=monkeypatch,
1664
-                    runner=runner,
1665
-                )
1666
-            )
1667
-            config_dir = cli_helpers.config_filename(subsystem=None)
1668
-            with contextlib.suppress(FileNotFoundError):
1669
-                shutil.rmtree(config_dir)
1670
-            config_dir.write_text("Obstruction!!\n")
1671
-            result = runner.invoke(
1672
-                cli.derivepassphrase_vault,
1673
-                ["--export", "-", *export_options],
1674
-                input="null",
1675
-                catch_exceptions=False,
1676
-            )
1677
-        assert result.error_exit(
1678
-            error="Cannot load vault settings:"
1679
-        ) or result.error_exit(error="Cannot load user config:"), (
1680
-            "expected error exit and known error message"
1681
-        )
1682
-
1683
-    @Parametrize.NOTES_PLACEMENT
1684
-    @hypothesis.given(
1685
-        notes=strategies.text(
1686
-            strategies.characters(
1687
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
1688
-            ),
1689
-            min_size=1,
1690
-            max_size=512,
1691
-        ).filter(str.strip),
1692
-    )
1693
-    def test_215_notes_placement(
1694
-        self,
1695
-        notes_placement: Literal["before", "after"],
1696
-        placement_args: list[str],
1697
-        notes: str,
1698
-    ) -> None:
1699
-        notes = notes.strip()
1700
-        maybe_notes = {"notes": notes} if notes else {}
1701
-        vault_config = {
1702
-            "global": {"phrase": DUMMY_PASSPHRASE},
1703
-            "services": {
1704
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
1705
-            },
1706
-        }
1707
-        result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii")
1708
-        expected = (
1709
-            f"{notes}\n\n{result_phrase}\n"
1710
-            if notes_placement == "before"
1711
-            else f"{result_phrase}\n\n{notes}\n\n"
1712
-        )
1713
-        runner = machinery.CliRunner(mix_stderr=True)
1714
-        # TODO(the-13th-letter): Rewrite using parenthesized
1715
-        # with-statements.
1716
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1717
-        with contextlib.ExitStack() as stack:
1718
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1719
-            stack.enter_context(
1720
-                pytest_machinery.isolated_vault_config(
1721
-                    monkeypatch=monkeypatch,
1722
-                    runner=runner,
1723
-                    vault_config=vault_config,
1724
-                )
1725
-            )
1726
-            result = runner.invoke(
1727
-                cli.derivepassphrase_vault,
1728
-                [*placement_args, "--", DUMMY_SERVICE],
1729
-                catch_exceptions=False,
1730
-            )
1731
-            assert result.clean_exit(output=expected), "expected clean exit"
1732
-
1733
-    @Parametrize.MODERN_EDITOR_INTERFACE
1734
-    @hypothesis.settings(
1735
-        suppress_health_check=[
1736
-            *hypothesis.settings().suppress_health_check,
1737
-            hypothesis.HealthCheck.function_scoped_fixture,
1738
-        ],
1739
-    )
1740
-    @hypothesis.given(
1741
-        notes=strategies.text(
1742
-            strategies.characters(
1743
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
1744
-            ),
1745
-            min_size=1,
1746
-            max_size=512,
1747
-        ).filter(str.strip),
1748
-    )
1749
-    def test_220_edit_notes_successfully(
1750
-        self,
1751
-        caplog: pytest.LogCaptureFixture,
1752
-        modern_editor_interface: bool,
1753
-        notes: str,
1754
-    ) -> None:
1755
-        """Editing notes works."""
1756
-        marker = cli_messages.TranslatedString(
1757
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1758
-        )
1759
-        edit_result = f"""
1760
-
1761
-{marker}
1762
-{notes}
1763
-"""
1764
-        # Reset caplog between hypothesis runs.
1765
-        caplog.clear()
1766
-        runner = machinery.CliRunner(mix_stderr=False)
1767
-        # TODO(the-13th-letter): Rewrite using parenthesized
1768
-        # with-statements.
1769
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1770
-        with contextlib.ExitStack() as stack:
1771
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1772
-            stack.enter_context(
1773
-                pytest_machinery.isolated_vault_config(
1774
-                    monkeypatch=monkeypatch,
1775
-                    runner=runner,
1776
-                    vault_config={
1777
-                        "global": {"phrase": "abc"},
1778
-                        "services": {"sv": {"notes": "Contents go here"}},
1779
-                    },
1780
-                )
1781
-            )
1782
-            notes_backup_file = cli_helpers.config_filename(
1783
-                subsystem="notes backup"
1784
-            )
1785
-            notes_backup_file.write_text(
1786
-                "These backup notes are left over from the previous session.",
1787
-                encoding="UTF-8",
1788
-            )
1789
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result)
1790
-            result = runner.invoke(
1791
-                cli.derivepassphrase_vault,
1792
-                [
1793
-                    "--config",
1794
-                    "--notes",
1795
-                    "--modern-editor-interface"
1796
-                    if modern_editor_interface
1797
-                    else "--vault-legacy-editor-interface",
1798
-                    "--",
1799
-                    "sv",
1800
-                ],
1801
-                catch_exceptions=False,
1802
-            )
1803
-            assert result.clean_exit(), "expected clean exit"
1804
-            assert all(map(is_warning_line, result.stderr.splitlines(True)))
1805
-            assert modern_editor_interface or machinery.warning_emitted(
1806
-                "A backup copy of the old notes was saved",
1807
-                caplog.record_tuples,
1808
-            ), "expected known warning message in stderr"
1809
-            assert (
1810
-                modern_editor_interface
1811
-                or notes_backup_file.read_text(encoding="UTF-8")
1812
-                == "Contents go here"
1813
-            )
1814
-            with cli_helpers.config_filename(subsystem="vault").open(
1815
-                encoding="UTF-8"
1816
-            ) as infile:
1817
-                config = json.load(infile)
1818
-            assert config == {
1819
-                "global": {"phrase": "abc"},
1820
-                "services": {
1821
-                    "sv": {
1822
-                        "notes": notes.strip()
1823
-                        if modern_editor_interface
1824
-                        else edit_result.strip()
1825
-                    }
1826
-                },
1827
-            }
1828
-
1829
-    @Parametrize.NOOP_EDIT_FUNCS
1830
-    @hypothesis.given(
1831
-        notes=strategies.text(
1832
-            strategies.characters(
1833
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
1834
-            ),
1835
-            min_size=1,
1836
-            max_size=512,
1837
-        ).filter(str.strip),
1838
-    )
1839
-    def test_221_edit_notes_noop(
1840
-        self,
1841
-        edit_func_name: Literal["empty", "space"],
1842
-        modern_editor_interface: bool,
1843
-        notes: str,
1844
-    ) -> None:
1845
-        """Abandoning edited notes works."""
1846
-
1847
-        def empty(text: str, *_args: Any, **_kwargs: Any) -> str:
1848
-            del text
1849
-            return ""
1850
-
1851
-        def space(text: str, *_args: Any, **_kwargs: Any) -> str:
1852
-            del text
1853
-            return "       " + notes.strip() + "\n\n\n\n\n\n"
1854
-
1855
-        edit_funcs = {"empty": empty, "space": space}
1856
-        runner = machinery.CliRunner(mix_stderr=False)
1857
-        # TODO(the-13th-letter): Rewrite using parenthesized
1858
-        # with-statements.
1859
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1860
-        with contextlib.ExitStack() as stack:
1861
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1862
-            stack.enter_context(
1863
-                pytest_machinery.isolated_vault_config(
1864
-                    monkeypatch=monkeypatch,
1865
-                    runner=runner,
1866
-                    vault_config={
1867
-                        "global": {"phrase": "abc"},
1868
-                        "services": {"sv": {"notes": notes.strip()}},
1869
-                    },
1870
-                )
1871
-            )
1872
-            notes_backup_file = cli_helpers.config_filename(
1873
-                subsystem="notes backup"
1874
-            )
1875
-            notes_backup_file.write_text(
1876
-                "These backup notes are left over from the previous session.",
1877
-                encoding="UTF-8",
1878
-            )
1879
-            monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name])
1880
-            result = runner.invoke(
1881
-                cli.derivepassphrase_vault,
1882
-                [
1883
-                    "--config",
1884
-                    "--notes",
1885
-                    "--modern-editor-interface"
1886
-                    if modern_editor_interface
1887
-                    else "--vault-legacy-editor-interface",
1888
-                    "--",
1889
-                    "sv",
1890
-                ],
1891
-                catch_exceptions=False,
1892
-            )
1893
-            assert result.clean_exit(empty_stderr=True) or result.error_exit(
1894
-                error="the user aborted the request"
1895
-            ), "expected clean exit"
1896
-            assert (
1897
-                modern_editor_interface
1898
-                or notes_backup_file.read_text(encoding="UTF-8")
1899
-                == "These backup notes are left over from the previous session."
1900
-            )
1901
-            with cli_helpers.config_filename(subsystem="vault").open(
1902
-                encoding="UTF-8"
1903
-            ) as infile:
1904
-                config = json.load(infile)
1905
-            assert config == {
1906
-                "global": {"phrase": "abc"},
1907
-                "services": {"sv": {"notes": notes.strip()}},
1908
-            }
1909
-
1910
-    # TODO(the-13th-letter): Keep this behavior or not, with or without
1911
-    # warning?
1912
-    @Parametrize.MODERN_EDITOR_INTERFACE
1913
-    @hypothesis.settings(
1914
-        suppress_health_check=[
1915
-            *hypothesis.settings().suppress_health_check,
1916
-            hypothesis.HealthCheck.function_scoped_fixture,
1917
-        ],
1918
-    )
1919
-    @hypothesis.given(
1920
-        notes=strategies.text(
1921
-            strategies.characters(
1922
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
1923
-            ),
1924
-            min_size=1,
1925
-            max_size=512,
1926
-        ).filter(str.strip),
1927
-    )
1928
-    def test_222_edit_notes_marker_removed(
1929
-        self,
1930
-        caplog: pytest.LogCaptureFixture,
1931
-        modern_editor_interface: bool,
1932
-        notes: str,
1933
-    ) -> None:
1934
-        """Removing the notes marker still saves the notes.
1935
-
1936
-        TODO: Keep this behavior or not, with or without warning?
1937
-
1938
-        """
1939
-        notes_marker = cli_messages.TranslatedString(
1940
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1941
-        )
1942
-        hypothesis.assume(str(notes_marker) not in notes.strip())
1943
-        # Reset caplog between hypothesis runs.
1944
-        caplog.clear()
1945
-        runner = machinery.CliRunner(mix_stderr=False)
1946
-        # TODO(the-13th-letter): Rewrite using parenthesized
1947
-        # with-statements.
1948
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1949
-        with contextlib.ExitStack() as stack:
1950
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1951
-            stack.enter_context(
1952
-                pytest_machinery.isolated_vault_config(
1953
-                    monkeypatch=monkeypatch,
1954
-                    runner=runner,
1955
-                    vault_config={
1956
-                        "global": {"phrase": "abc"},
1957
-                        "services": {"sv": {"notes": "Contents go here"}},
1958
-                    },
1959
-                )
1960
-            )
1961
-            notes_backup_file = cli_helpers.config_filename(
1962
-                subsystem="notes backup"
1963
-            )
1964
-            notes_backup_file.write_text(
1965
-                "These backup notes are left over from the previous session.",
1966
-                encoding="UTF-8",
1967
-            )
1968
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: notes)
1969
-            result = runner.invoke(
1970
-                cli.derivepassphrase_vault,
1971
-                [
1972
-                    "--config",
1973
-                    "--notes",
1974
-                    "--modern-editor-interface"
1975
-                    if modern_editor_interface
1976
-                    else "--vault-legacy-editor-interface",
1977
-                    "--",
1978
-                    "sv",
1979
-                ],
1980
-                catch_exceptions=False,
1981
-            )
1982
-            assert result.clean_exit(), "expected clean exit"
1983
-            assert not result.stderr or all(
1984
-                map(is_warning_line, result.stderr.splitlines(True))
1985
-            )
1986
-            assert not caplog.record_tuples or machinery.warning_emitted(
1987
-                "A backup copy of the old notes was saved",
1988
-                caplog.record_tuples,
1989
-            ), "expected known warning message in stderr"
1990
-            assert (
1991
-                modern_editor_interface
1992
-                or notes_backup_file.read_text(encoding="UTF-8")
1993
-                == "Contents go here"
1994
-            )
1995
-            with cli_helpers.config_filename(subsystem="vault").open(
1996
-                encoding="UTF-8"
1997
-            ) as infile:
1998
-                config = json.load(infile)
1999
-            assert config == {
2000
-                "global": {"phrase": "abc"},
2001
-                "services": {"sv": {"notes": notes.strip()}},
2002
-            }
2003
-
2004
-    @hypothesis.given(
2005
-        notes=strategies.text(
2006
-            strategies.characters(
2007
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
2008
-            ),
2009
-            min_size=1,
2010
-            max_size=512,
2011
-        ).filter(str.strip),
2012
-    )
2013
-    def test_223_edit_notes_abort(
2014
-        self,
2015
-        notes: str,
2016
-    ) -> None:
2017
-        """Aborting editing notes works.
2018
-
2019
-        Aborting is only supported with the modern editor interface.
2020
-
2021
-        """
2022
-        runner = machinery.CliRunner(mix_stderr=False)
2023
-        # TODO(the-13th-letter): Rewrite using parenthesized
2024
-        # with-statements.
2025
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2026
-        with contextlib.ExitStack() as stack:
2027
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2028
-            stack.enter_context(
2029
-                pytest_machinery.isolated_vault_config(
2030
-                    monkeypatch=monkeypatch,
2031
-                    runner=runner,
2032
-                    vault_config={
2033
-                        "global": {"phrase": "abc"},
2034
-                        "services": {"sv": {"notes": notes.strip()}},
2035
-                    },
2036
-                )
2037
-            )
2038
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
2039
-            result = runner.invoke(
2040
-                cli.derivepassphrase_vault,
2041
-                [
2042
-                    "--config",
2043
-                    "--notes",
2044
-                    "--modern-editor-interface",
2045
-                    "--",
2046
-                    "sv",
2047
-                ],
2048
-                catch_exceptions=False,
2049
-            )
2050
-            assert result.error_exit(error="the user aborted the request"), (
2051
-                "expected known error message"
2052
-            )
2053
-            with cli_helpers.config_filename(subsystem="vault").open(
2054
-                encoding="UTF-8"
2055
-            ) as infile:
2056
-                config = json.load(infile)
2057
-            assert config == {
2058
-                "global": {"phrase": "abc"},
2059
-                "services": {"sv": {"notes": notes.strip()}},
2060
-            }
2061
-
2062
-    def test_223a_edit_empty_notes_abort(
2063
-        self,
2064
-    ) -> None:
2065
-        """Aborting editing notes works even if no notes are stored yet.
2066
-
2067
-        Aborting is only supported with the modern editor interface.
2068
-
2069
-        """
2070
-        runner = machinery.CliRunner(mix_stderr=False)
2071
-        # TODO(the-13th-letter): Rewrite using parenthesized
2072
-        # with-statements.
2073
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2074
-        with contextlib.ExitStack() as stack:
2075
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2076
-            stack.enter_context(
2077
-                pytest_machinery.isolated_vault_config(
2078
-                    monkeypatch=monkeypatch,
2079
-                    runner=runner,
2080
-                    vault_config={
2081
-                        "global": {"phrase": "abc"},
2082
-                        "services": {},
2083
-                    },
2084
-                )
2085
-            )
2086
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
2087
-            result = runner.invoke(
2088
-                cli.derivepassphrase_vault,
2089
-                [
2090
-                    "--config",
2091
-                    "--notes",
2092
-                    "--modern-editor-interface",
2093
-                    "--",
2094
-                    "sv",
2095
-                ],
2096
-                catch_exceptions=False,
2097
-            )
2098
-            assert result.error_exit(error="the user aborted the request"), (
2099
-                "expected known error message"
2100
-            )
2101
-            with cli_helpers.config_filename(subsystem="vault").open(
2102
-                encoding="UTF-8"
2103
-            ) as infile:
2104
-                config = json.load(infile)
2105
-            assert config == {
2106
-                "global": {"phrase": "abc"},
2107
-                "services": {},
2108
-            }
2109
-
2110
-    @Parametrize.MODERN_EDITOR_INTERFACE
2111
-    @hypothesis.settings(
2112
-        suppress_health_check=[
2113
-            *hypothesis.settings().suppress_health_check,
2114
-            hypothesis.HealthCheck.function_scoped_fixture,
2115
-        ],
2116
-    )
2117
-    @hypothesis.given(
2118
-        notes=strategies.text(
2119
-            strategies.characters(
2120
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
2121
-            ),
2122
-            max_size=512,
2123
-        ),
2124
-    )
2125
-    def test_223b_edit_notes_fail_config_option_missing(
2126
-        self,
2127
-        caplog: pytest.LogCaptureFixture,
2128
-        modern_editor_interface: bool,
2129
-        notes: str,
2130
-    ) -> None:
2131
-        """Editing notes fails (and warns) if `--config` is missing."""
2132
-        maybe_notes = {"notes": notes.strip()} if notes.strip() else {}
2133
-        vault_config = {
2134
-            "global": {"phrase": DUMMY_PASSPHRASE},
2135
-            "services": {
2136
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
2137
-            },
2138
-        }
2139
-        # Reset caplog between hypothesis runs.
2140
-        caplog.clear()
2141
-        runner = machinery.CliRunner(mix_stderr=False)
2142
-        # TODO(the-13th-letter): Rewrite using parenthesized
2143
-        # with-statements.
2144
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2145
-        with contextlib.ExitStack() as stack:
2146
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2147
-            stack.enter_context(
2148
-                pytest_machinery.isolated_vault_config(
2149
-                    monkeypatch=monkeypatch,
2150
-                    runner=runner,
2151
-                    vault_config=vault_config,
2152
-                )
2153
-            )
2154
-            EDIT_ATTEMPTED = "edit attempted!"  # noqa: N806
2155
-
2156
-            def raiser(*_args: Any, **_kwargs: Any) -> NoReturn:
2157
-                pytest.fail(EDIT_ATTEMPTED)
2158
-
2159
-            notes_backup_file = cli_helpers.config_filename(
2160
-                subsystem="notes backup"
2161
-            )
2162
-            notes_backup_file.write_text(
2163
-                "These backup notes are left over from the previous session.",
2164
-                encoding="UTF-8",
2165
-            )
2166
-            monkeypatch.setattr(click, "edit", raiser)
2167
-            result = runner.invoke(
2168
-                cli.derivepassphrase_vault,
2169
-                [
2170
-                    "--notes",
2171
-                    "--modern-editor-interface"
2172
-                    if modern_editor_interface
2173
-                    else "--vault-legacy-editor-interface",
2174
-                    "--",
2175
-                    DUMMY_SERVICE,
2176
-                ],
2177
-                catch_exceptions=False,
2178
-            )
2179
-            assert result.clean_exit(
2180
-                output=DUMMY_RESULT_PASSPHRASE.decode("ascii")
2181
-            ), "expected clean exit"
2182
-            assert result.stderr
2183
-            assert notes.strip() in result.stderr
2184
-            assert all(
2185
-                is_warning_line(line)
2186
-                for line in result.stderr.splitlines(True)
2187
-                if line.startswith(f"{cli.PROG_NAME}: ")
2188
-            )
2189
-            assert machinery.warning_emitted(
2190
-                "Specifying --notes without --config is ineffective.  "
2191
-                "No notes will be edited.",
2192
-                caplog.record_tuples,
2193
-            ), "expected known warning message in stderr"
2194
-            assert (
2195
-                modern_editor_interface
2196
-                or notes_backup_file.read_text(encoding="UTF-8")
2197
-                == "These backup notes are left over from the previous session."
2198
-            )
2199
-            with cli_helpers.config_filename(subsystem="vault").open(
2200
-                encoding="UTF-8"
2201
-            ) as infile:
2202
-                config = json.load(infile)
2203
-            assert config == vault_config
2204
-
2205
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
2206
-    def test_224_store_config_good(
2207
-        self,
2208
-        command_line: list[str],
2209
-        input: str,
2210
-        result_config: Any,
2211
-    ) -> None:
2212
-        """Storing valid settings via `--config` works.
2213
-
2214
-        The format also contains embedded newlines and indentation to make
2215
-        the config more readable.
2216
-
2217
-        """
2218
-        runner = machinery.CliRunner(mix_stderr=False)
2219
-        # TODO(the-13th-letter): Rewrite using parenthesized
2220
-        # with-statements.
2221
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2222
-        with contextlib.ExitStack() as stack:
2223
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2224
-            stack.enter_context(
2225
-                pytest_machinery.isolated_vault_config(
2226
-                    monkeypatch=monkeypatch,
2227
-                    runner=runner,
2228
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2229
-                )
2230
-            )
2231
-            monkeypatch.setattr(
2232
-                cli_helpers,
2233
-                "get_suitable_ssh_keys",
2234
-                callables.suitable_ssh_keys,
2235
-            )
2236
-            result = runner.invoke(
2237
-                cli.derivepassphrase_vault,
2238
-                ["--config", *command_line],
2239
-                catch_exceptions=False,
2240
-                input=input,
2241
-            )
2242
-            assert result.clean_exit(), "expected clean exit"
2243
-            config_txt = cli_helpers.config_filename(
2244
-                subsystem="vault"
2245
-            ).read_text(encoding="UTF-8")
2246
-            config = json.loads(config_txt)
2247
-            assert config == result_config, (
2248
-                "stored config does not match expectation"
2249
-            )
2250
-            assert_vault_config_is_indented_and_line_broken(config_txt)
2251
-
2252
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
2253
-    def test_225_store_config_fail(
2254
-        self,
2255
-        command_line: list[str],
2256
-        input: str,
2257
-        err_text: str,
2258
-    ) -> None:
2259
-        """Storing invalid settings via `--config` fails."""
2260
-        runner = machinery.CliRunner(mix_stderr=False)
2261
-        # TODO(the-13th-letter): Rewrite using parenthesized
2262
-        # with-statements.
2263
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2264
-        with contextlib.ExitStack() as stack:
2265
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2266
-            stack.enter_context(
2267
-                pytest_machinery.isolated_vault_config(
2268
-                    monkeypatch=monkeypatch,
2269
-                    runner=runner,
2270
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2271
-                )
2272
-            )
2273
-            monkeypatch.setattr(
2274
-                cli_helpers,
2275
-                "get_suitable_ssh_keys",
2276
-                callables.suitable_ssh_keys,
2277
-            )
2278
-            result = runner.invoke(
2279
-                cli.derivepassphrase_vault,
2280
-                ["--config", *command_line],
2281
-                catch_exceptions=False,
2282
-                input=input,
2283
-            )
2284
-        assert result.error_exit(error=err_text), (
2285
-            "expected error exit and known error message"
2286
-        )
2287
-
2288
-    def test_225a_store_config_fail_manual_no_ssh_key_selection(
2289
-        self,
2290
-        running_ssh_agent: data.RunningSSHAgentInfo,
2291
-    ) -> None:
2292
-        """Not selecting an SSH key during `--config --key` fails."""
2293
-        del running_ssh_agent
2294
-        runner = machinery.CliRunner(mix_stderr=False)
2295
-        # TODO(the-13th-letter): Rewrite using parenthesized
2296
-        # with-statements.
2297
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2298
-        with contextlib.ExitStack() as stack:
2299
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2300
-            stack.enter_context(
2301
-                pytest_machinery.isolated_vault_config(
2302
-                    monkeypatch=monkeypatch,
2303
-                    runner=runner,
2304
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2305
-                )
2306
-            )
2307
-
2308
-            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
2309
-                raise IndexError(cli_helpers.EMPTY_SELECTION)
2310
-
2311
-            monkeypatch.setattr(
2312
-                cli_helpers, "prompt_for_selection", prompt_for_selection
2313
-            )
2314
-            # Also patch the list of suitable SSH keys, lest we be at
2315
-            # the mercy of whatever SSH agent may be running.
2316
-            monkeypatch.setattr(
2317
-                cli_helpers,
2318
-                "get_suitable_ssh_keys",
2319
-                callables.suitable_ssh_keys,
2320
-            )
2321
-            result = runner.invoke(
2322
-                cli.derivepassphrase_vault,
2323
-                ["--key", "--config"],
2324
-                catch_exceptions=False,
2325
-            )
2326
-        assert result.error_exit(error="the user aborted the request"), (
2327
-            "expected error exit and known error message"
2328
-        )
2329
-
2330
-    def test_225b_store_config_fail_manual_no_ssh_agent(
2331
-        self,
2332
-        running_ssh_agent: data.RunningSSHAgentInfo,
2333
-    ) -> None:
2334
-        """Not running an SSH agent during `--config --key` fails."""
2335
-        del running_ssh_agent
2336
-        runner = machinery.CliRunner(mix_stderr=False)
2337
-        # TODO(the-13th-letter): Rewrite using parenthesized
2338
-        # with-statements.
2339
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2340
-        with contextlib.ExitStack() as stack:
2341
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2342
-            stack.enter_context(
2343
-                pytest_machinery.isolated_vault_config(
2344
-                    monkeypatch=monkeypatch,
2345
-                    runner=runner,
2346
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2347
-                )
2348
-            )
2349
-            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
2350
-            result = runner.invoke(
2351
-                cli.derivepassphrase_vault,
2352
-                ["--key", "--config"],
2353
-                catch_exceptions=False,
2354
-            )
2355
-        assert result.error_exit(error="Cannot find any running SSH agent"), (
2356
-            "expected error exit and known error message"
2357
-        )
2358
-
2359
-    def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
2360
-        self,
2361
-        running_ssh_agent: data.RunningSSHAgentInfo,
2362
-    ) -> None:
2363
-        """Not running a reachable SSH agent during `--config --key` fails."""
2364
-        running_ssh_agent.require_external_address()
2365
-        runner = machinery.CliRunner(mix_stderr=False)
2366
-        # TODO(the-13th-letter): Rewrite using parenthesized
2367
-        # with-statements.
2368
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2369
-        with contextlib.ExitStack() as stack:
2370
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2371
-            stack.enter_context(
2372
-                pytest_machinery.isolated_vault_config(
2373
-                    monkeypatch=monkeypatch,
2374
-                    runner=runner,
2375
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2376
-                )
2377
-            )
2378
-            cwd = pathlib.Path.cwd().resolve()
2379
-            monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd))
2380
-            result = runner.invoke(
2381
-                cli.derivepassphrase_vault,
2382
-                ["--key", "--config"],
2383
-                catch_exceptions=False,
2384
-            )
2385
-        assert result.error_exit(error="Cannot connect to the SSH agent"), (
2386
-            "expected error exit and known error message"
2387
-        )
2388
-
2389
-    @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
2390
-    def test_225d_store_config_fail_manual_read_only_file(
2391
-        self,
2392
-        try_race_free_implementation: bool,
2393
-    ) -> None:
2394
-        """Using a read-only configuration file with `--config` fails."""
2395
-        runner = machinery.CliRunner(mix_stderr=False)
2396
-        # TODO(the-13th-letter): Rewrite using parenthesized
2397
-        # with-statements.
2398
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2399
-        with contextlib.ExitStack() as stack:
2400
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2401
-            stack.enter_context(
2402
-                pytest_machinery.isolated_vault_config(
2403
-                    monkeypatch=monkeypatch,
2404
-                    runner=runner,
2405
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2406
-                )
2407
-            )
2408
-            callables.make_file_readonly(
2409
-                cli_helpers.config_filename(subsystem="vault"),
2410
-                try_race_free_implementation=try_race_free_implementation,
2411
-            )
2412
-            result = runner.invoke(
2413
-                cli.derivepassphrase_vault,
2414
-                ["--config", "--length=15", "--", DUMMY_SERVICE],
2415
-                catch_exceptions=False,
2416
-            )
2417
-        assert result.error_exit(error="Cannot store vault settings:"), (
2418
-            "expected error exit and known error message"
2419
-        )
2420
-
2421
-    def test_225e_store_config_fail_manual_custom_error(
2422
-        self,
2423
-    ) -> None:
2424
-        """OS-erroring with `--config` fails."""
2425
-        runner = machinery.CliRunner(mix_stderr=False)
2426
-        # TODO(the-13th-letter): Rewrite using parenthesized
2427
-        # with-statements.
2428
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2429
-        with contextlib.ExitStack() as stack:
2430
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2431
-            stack.enter_context(
2432
-                pytest_machinery.isolated_vault_config(
2433
-                    monkeypatch=monkeypatch,
2434
-                    runner=runner,
2435
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2436
-                )
2437
-            )
2438
-            custom_error = "custom error message"
2439
-
2440
-            def raiser(config: Any) -> None:
2441
-                del config
2442
-                raise RuntimeError(custom_error)
2443
-
2444
-            monkeypatch.setattr(cli_helpers, "save_config", raiser)
2445
-            result = runner.invoke(
2446
-                cli.derivepassphrase_vault,
2447
-                ["--config", "--length=15", "--", DUMMY_SERVICE],
2448
-                catch_exceptions=False,
2449
-            )
2450
-        assert result.error_exit(error=custom_error), (
2451
-            "expected error exit and known error message"
2452
-        )
2453
-
2454
-    def test_225f_store_config_fail_unset_and_set_same_settings(
2455
-        self,
2456
-    ) -> None:
2457
-        """Issuing conflicting settings to `--config` fails."""
2458
-        runner = machinery.CliRunner(mix_stderr=False)
2459
-        # TODO(the-13th-letter): Rewrite using parenthesized
2460
-        # with-statements.
2461
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2462
-        with contextlib.ExitStack() as stack:
2463
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2464
-            stack.enter_context(
2465
-                pytest_machinery.isolated_vault_config(
2466
-                    monkeypatch=monkeypatch,
2467
-                    runner=runner,
2468
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2469
-                )
2470
-            )
2471
-            result = runner.invoke(
2472
-                cli.derivepassphrase_vault,
2473
-                [
2474
-                    "--config",
2475
-                    "--unset=length",
2476
-                    "--length=15",
2477
-                    "--",
2478
-                    DUMMY_SERVICE,
2479
-                ],
2480
-                catch_exceptions=False,
2481
-            )
2482
-        assert result.error_exit(
2483
-            error="Attempted to unset and set --length at the same time."
2484
-        ), "expected error exit and known error message"
2485
-
2486
-    def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded(
2487
-        self,
2488
-        running_ssh_agent: data.RunningSSHAgentInfo,
2489
-    ) -> None:
2490
-        """Not holding any SSH keys during `--config --key` fails."""
2491
-        del running_ssh_agent
2492
-        runner = machinery.CliRunner(mix_stderr=False)
2493
-        # TODO(the-13th-letter): Rewrite using parenthesized
2494
-        # with-statements.
2495
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2496
-        with contextlib.ExitStack() as stack:
2497
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2498
-            stack.enter_context(
2499
-                pytest_machinery.isolated_vault_config(
2500
-                    monkeypatch=monkeypatch,
2501
-                    runner=runner,
2502
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2503
-                )
2504
-            )
2505
-
2506
-            def func(
2507
-                *_args: Any,
2508
-                **_kwargs: Any,
2509
-            ) -> list[_types.SSHKeyCommentPair]:
2510
-                return []
2511
-
2512
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2513
-            result = runner.invoke(
2514
-                cli.derivepassphrase_vault,
2515
-                ["--key", "--config"],
2516
-                catch_exceptions=False,
2517
-            )
2518
-        assert result.error_exit(error="no keys suitable"), (
2519
-            "expected error exit and known error message"
2520
-        )
2521
-
2522
-    def test_225h_store_config_fail_manual_ssh_agent_runtime_error(
2523
-        self,
2524
-        running_ssh_agent: data.RunningSSHAgentInfo,
2525
-    ) -> None:
2526
-        """The SSH agent erroring during `--config --key` fails."""
2527
-        del running_ssh_agent
2528
-        runner = machinery.CliRunner(mix_stderr=False)
2529
-        # TODO(the-13th-letter): Rewrite using parenthesized
2530
-        # with-statements.
2531
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2532
-        with contextlib.ExitStack() as stack:
2533
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2534
-            stack.enter_context(
2535
-                pytest_machinery.isolated_vault_config(
2536
-                    monkeypatch=monkeypatch,
2537
-                    runner=runner,
2538
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2539
-                )
2540
-            )
2541
-
2542
-            def raiser(*_args: Any, **_kwargs: Any) -> None:
2543
-                raise ssh_agent.TrailingDataError()
2544
-
2545
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
2546
-            result = runner.invoke(
2547
-                cli.derivepassphrase_vault,
2548
-                ["--key", "--config"],
2549
-                catch_exceptions=False,
2550
-            )
2551
-        assert result.error_exit(
2552
-            error="violates the communication protocol."
2553
-        ), "expected error exit and known error message"
2554
-
2555
-    def test_225i_store_config_fail_manual_ssh_agent_refuses(
2556
-        self,
2557
-        running_ssh_agent: data.RunningSSHAgentInfo,
2558
-    ) -> None:
2559
-        """The SSH agent refusing during `--config --key` fails."""
2560
-        del running_ssh_agent
2561
-        runner = machinery.CliRunner(mix_stderr=False)
2562
-        # TODO(the-13th-letter): Rewrite using parenthesized
2563
-        # with-statements.
2564
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2565
-        with contextlib.ExitStack() as stack:
2566
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2567
-            stack.enter_context(
2568
-                pytest_machinery.isolated_vault_config(
2569
-                    monkeypatch=monkeypatch,
2570
-                    runner=runner,
2571
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2572
-                )
2573
-            )
2574
-
2575
-            def func(*_args: Any, **_kwargs: Any) -> NoReturn:
2576
-                raise ssh_agent.SSHAgentFailedError(
2577
-                    _types.SSH_AGENT.FAILURE, b""
2578
-                )
2579
-
2580
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2581
-            result = runner.invoke(
2582
-                cli.derivepassphrase_vault,
2583
-                ["--key", "--config"],
2584
-                catch_exceptions=False,
2585
-            )
2586
-        assert result.error_exit(error="refused to"), (
2587
-            "expected error exit and known error message"
2588
-        )
2589
-
2590
-    def test_226_no_arguments(self) -> None:
2591
-        """Calling `derivepassphrase vault` without any arguments fails."""
2592
-        runner = machinery.CliRunner(mix_stderr=False)
2593
-        # TODO(the-13th-letter): Rewrite using parenthesized
2594
-        # with-statements.
2595
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2596
-        with contextlib.ExitStack() as stack:
2597
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2598
-            stack.enter_context(
2599
-                pytest_machinery.isolated_config(
2600
-                    monkeypatch=monkeypatch,
2601
-                    runner=runner,
2602
-                )
2603
-            )
2604
-            result = runner.invoke(
2605
-                cli.derivepassphrase_vault, [], catch_exceptions=False
2606
-            )
2607
-        assert result.error_exit(
2608
-            error="Deriving a passphrase requires a SERVICE"
2609
-        ), "expected error exit and known error message"
2610
-
2611
-    def test_226a_no_passphrase_or_key(
2612
-        self,
2613
-    ) -> None:
2614
-        """Deriving a passphrase without a passphrase or key fails."""
2615
-        runner = machinery.CliRunner(mix_stderr=False)
2616
-        # TODO(the-13th-letter): Rewrite using parenthesized
2617
-        # with-statements.
2618
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2619
-        with contextlib.ExitStack() as stack:
2620
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2621
-            stack.enter_context(
2622
-                pytest_machinery.isolated_config(
2623
-                    monkeypatch=monkeypatch,
2624
-                    runner=runner,
2625
-                )
2626
-            )
2627
-            result = runner.invoke(
2628
-                cli.derivepassphrase_vault,
2629
-                ["--", DUMMY_SERVICE],
2630
-                catch_exceptions=False,
2631
-            )
2632
-        assert result.error_exit(error="No passphrase or key was given"), (
2633
-            "expected error exit and known error message"
2634
-        )
2635
-
2636
-    def test_230_config_directory_nonexistant(
2637
-        self,
2638
-    ) -> None:
2639
-        """Running without an existing config directory works.
2640
-
2641
-        This is a regression test; see [issue\u00a0#6][] for context.
2642
-
2643
-        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
2644
-
2645
-        """
2646
-        runner = machinery.CliRunner(mix_stderr=False)
2647
-        # TODO(the-13th-letter): Rewrite using parenthesized
2648
-        # with-statements.
2649
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2650
-        with contextlib.ExitStack() as stack:
2651
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2652
-            stack.enter_context(
2653
-                pytest_machinery.isolated_config(
2654
-                    monkeypatch=monkeypatch,
2655
-                    runner=runner,
2656
-                )
2657
-            )
2658
-            with contextlib.suppress(FileNotFoundError):
2659
-                shutil.rmtree(cli_helpers.config_filename(subsystem=None))
2660
-            result = runner.invoke(
2661
-                cli.derivepassphrase_vault,
2662
-                ["--config", "-p"],
2663
-                catch_exceptions=False,
2664
-                input="abc\n",
2665
-            )
2666
-            assert result.clean_exit(), "expected clean exit"
2667
-            assert result.stderr == "Passphrase:", (
2668
-                "program unexpectedly failed?!"
2669
-            )
2670
-            with cli_helpers.config_filename(subsystem="vault").open(
2671
-                encoding="UTF-8"
2672
-            ) as infile:
2673
-                config_readback = json.load(infile)
2674
-            assert config_readback == {
2675
-                "global": {"phrase": "abc"},
2676
-                "services": {},
2677
-            }, "config mismatch"
2678
-
2679
-    def test_230a_config_directory_not_a_file(
2680
-        self,
2681
-    ) -> None:
2682
-        """Erroring without an existing config directory errors normally.
2683
-
2684
-        That is, the missing configuration directory does not cause any
2685
-        errors by itself.
2686
-
2687
-        This is a regression test; see [issue\u00a0#6][] for context.
2688
-
2689
-        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
2690
-
2691
-        """
2692
-        runner = machinery.CliRunner(mix_stderr=False)
2693
-        # TODO(the-13th-letter): Rewrite using parenthesized
2694
-        # with-statements.
2695
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2696
-        with contextlib.ExitStack() as stack:
2697
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2698
-            stack.enter_context(
2699
-                pytest_machinery.isolated_config(
2700
-                    monkeypatch=monkeypatch,
2701
-                    runner=runner,
2702
-                )
2703
-            )
2704
-            save_config_ = cli_helpers.save_config
2705
-
2706
-            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
2707
-                config_dir = cli_helpers.config_filename(subsystem=None)
2708
-                with contextlib.suppress(FileNotFoundError):
2709
-                    shutil.rmtree(config_dir)
2710
-                config_dir.write_text("Obstruction!!\n")
2711
-                monkeypatch.setattr(cli_helpers, "save_config", save_config_)
2712
-                return save_config_(*args, **kwargs)
2713
-
2714
-            monkeypatch.setattr(
2715
-                cli_helpers, "save_config", obstruct_config_saving
2716
-            )
2717
-            result = runner.invoke(
2718
-                cli.derivepassphrase_vault,
2719
-                ["--config", "-p"],
2720
-                catch_exceptions=False,
2721
-                input="abc\n",
2722
-            )
2723
-            assert result.error_exit(error="Cannot store vault settings:"), (
2724
-                "expected error exit and known error message"
2725
-            )
2726
-
2727
-    def test_230b_store_config_custom_error(
2728
-        self,
2729
-    ) -> None:
2730
-        """Storing the configuration reacts even to weird errors."""
2731
-        runner = machinery.CliRunner(mix_stderr=False)
2732
-        # TODO(the-13th-letter): Rewrite using parenthesized
2733
-        # with-statements.
2734
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2735
-        with contextlib.ExitStack() as stack:
2736
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2737
-            stack.enter_context(
2738
-                pytest_machinery.isolated_config(
2739
-                    monkeypatch=monkeypatch,
2740
-                    runner=runner,
2741
-                )
2742
-            )
2743
-            custom_error = "custom error message"
2744
-
2745
-            def raiser(config: Any) -> None:
2746
-                del config
2747
-                raise RuntimeError(custom_error)
2748
-
2749
-            monkeypatch.setattr(cli_helpers, "save_config", raiser)
2750
-            result = runner.invoke(
2751
-                cli.derivepassphrase_vault,
2752
-                ["--config", "-p"],
2753
-                catch_exceptions=False,
2754
-                input="abc\n",
2755
-            )
2756
-            assert result.error_exit(error=custom_error), (
2757
-                "expected error exit and known error message"
2758
-            )
2759
-
2760
-    @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
2761
-    def test_300_unicode_normalization_form_warning(
2762
-        self,
2763
-        caplog: pytest.LogCaptureFixture,
2764
-        main_config: str,
2765
-        command_line: list[str],
2766
-        input: str | None,
2767
-        warning_message: str,
2768
-    ) -> None:
2769
-        """Using unnormalized Unicode passphrases warns."""
2770
-        runner = machinery.CliRunner(mix_stderr=False)
2771
-        # TODO(the-13th-letter): Rewrite using parenthesized
2772
-        # with-statements.
2773
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2774
-        with contextlib.ExitStack() as stack:
2775
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2776
-            stack.enter_context(
2777
-                pytest_machinery.isolated_vault_config(
2778
-                    monkeypatch=monkeypatch,
2779
-                    runner=runner,
2780
-                    vault_config={
2781
-                        "services": {
2782
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2783
-                        }
2784
-                    },
2785
-                    main_config_str=main_config,
2786
-                )
2787
-            )
2788
-            result = runner.invoke(
2789
-                cli.derivepassphrase_vault,
2790
-                ["--debug", *command_line],
2791
-                catch_exceptions=False,
2792
-                input=input,
2793
-            )
2794
-        assert result.clean_exit(), "expected clean exit"
2795
-        assert machinery.warning_emitted(
2796
-            warning_message, caplog.record_tuples
2797
-        ), "expected known warning message in stderr"
2798
-
2799
-    @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
2800
-    def test_301_unicode_normalization_form_error(
2801
-        self,
2802
-        main_config: str,
2803
-        command_line: list[str],
2804
-        input: str | None,
2805
-        error_message: str,
2806
-    ) -> None:
2807
-        """Using unknown Unicode normalization forms fails."""
2808
-        runner = machinery.CliRunner(mix_stderr=False)
2809
-        # TODO(the-13th-letter): Rewrite using parenthesized
2810
-        # with-statements.
2811
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2812
-        with contextlib.ExitStack() as stack:
2813
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2814
-            stack.enter_context(
2815
-                pytest_machinery.isolated_vault_config(
2816
-                    monkeypatch=monkeypatch,
2817
-                    runner=runner,
2818
-                    vault_config={
2819
-                        "services": {
2820
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2821
-                        }
2822
-                    },
2823
-                    main_config_str=main_config,
2824
-                )
2825
-            )
2826
-            result = runner.invoke(
2827
-                cli.derivepassphrase_vault,
2828
-                command_line,
2829
-                catch_exceptions=False,
2830
-                input=input,
2831
-            )
2832
-        assert result.error_exit(
2833
-            error="The user configuration file is invalid."
2834
-        ), "expected error exit and known error message"
2835
-        assert result.error_exit(error=error_message), (
2836
-            "expected error exit and known error message"
2837
-        )
2838
-
2839
-    @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES
2840
-    def test_301a_unicode_normalization_form_error_from_stored_config(
2841
-        self,
2842
-        command_line: list[str],
2843
-    ) -> None:
2844
-        """Using unknown Unicode normalization forms in the config fails."""
2845
-        runner = machinery.CliRunner(mix_stderr=False)
2846
-        # TODO(the-13th-letter): Rewrite using parenthesized
2847
-        # with-statements.
2848
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2849
-        with contextlib.ExitStack() as stack:
2850
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2851
-            stack.enter_context(
2852
-                pytest_machinery.isolated_vault_config(
2853
-                    monkeypatch=monkeypatch,
2854
-                    runner=runner,
2855
-                    vault_config={
2856
-                        "services": {
2857
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2858
-                        }
2859
-                    },
2860
-                    main_config_str=(
2861
-                        "[vault]\ndefault-unicode-normalization-form = 'XXX'\n"
2862
-                    ),
2863
-                )
2864
-            )
2865
-            result = runner.invoke(
2866
-                cli.derivepassphrase_vault,
2867
-                command_line,
2868
-                input=DUMMY_PASSPHRASE,
2869
-                catch_exceptions=False,
2870
-            )
2871
-            assert result.error_exit(
2872
-                error="The user configuration file is invalid."
2873
-            ), "expected error exit and known error message"
2874
-            assert result.error_exit(
2875
-                error=(
2876
-                    "Invalid value 'XXX' for config key "
2877
-                    "vault.default-unicode-normalization-form"
2878
-                ),
2879
-            ), "expected error exit and known error message"
2880
-
2881
-    def test_310_bad_user_config_file(
2882
-        self,
2883
-    ) -> None:
2884
-        """Loading a user configuration file in an invalid format fails."""
2885
-        runner = machinery.CliRunner(mix_stderr=False)
2886
-        # TODO(the-13th-letter): Rewrite using parenthesized
2887
-        # with-statements.
2888
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2889
-        with contextlib.ExitStack() as stack:
2890
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2891
-            stack.enter_context(
2892
-                pytest_machinery.isolated_vault_config(
2893
-                    monkeypatch=monkeypatch,
2894
-                    runner=runner,
2895
-                    vault_config={"services": {}},
2896
-                    main_config_str="This file is not valid TOML.\n",
2897
-                )
2898
-            )
2899
-            result = runner.invoke(
2900
-                cli.derivepassphrase_vault,
2901
-                ["--phrase", "--", DUMMY_SERVICE],
2902
-                input=DUMMY_PASSPHRASE,
2903
-                catch_exceptions=False,
2904
-            )
2905
-            assert result.error_exit(error="Cannot load user config:"), (
2906
-                "expected error exit and known error message"
2907
-            )
2908
-
2909
-    def test_311_bad_user_config_is_a_directory(
2910
-        self,
2911
-    ) -> None:
2912
-        """Loading a user configuration file in an invalid format fails."""
2913
-        runner = machinery.CliRunner(mix_stderr=False)
2914
-        # TODO(the-13th-letter): Rewrite using parenthesized
2915
-        # with-statements.
2916
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2917
-        with contextlib.ExitStack() as stack:
2918
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2919
-            stack.enter_context(
2920
-                pytest_machinery.isolated_vault_config(
2921
-                    monkeypatch=monkeypatch,
2922
-                    runner=runner,
2923
-                    vault_config={"services": {}},
2924
-                    main_config_str="",
2925
-                )
2926
-            )
2927
-            user_config = cli_helpers.config_filename(
2928
-                subsystem="user configuration"
2929
-            )
2930
-            user_config.unlink()
2931
-            user_config.mkdir(parents=True, exist_ok=True)
2932
-            result = runner.invoke(
2933
-                cli.derivepassphrase_vault,
2934
-                ["--phrase", "--", DUMMY_SERVICE],
2935
-                input=DUMMY_PASSPHRASE,
2936
-                catch_exceptions=False,
2937
-            )
2938
-            assert result.error_exit(error="Cannot load user config:"), (
2939
-                "expected error exit and known error message"
2940
-            )
2941
-
2942
-    def test_400_missing_af_unix_support(
2943
-        self,
2944
-        caplog: pytest.LogCaptureFixture,
2945
-    ) -> None:
2946
-        """Querying the SSH agent without `AF_UNIX` support fails."""
2947
-        runner = machinery.CliRunner(mix_stderr=False)
2948
-        # TODO(the-13th-letter): Rewrite using parenthesized
2949
-        # with-statements.
2950
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2951
-        with contextlib.ExitStack() as stack:
2952
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2953
-            stack.enter_context(
2954
-                pytest_machinery.isolated_vault_config(
2955
-                    monkeypatch=monkeypatch,
2956
-                    runner=runner,
2957
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2958
-                )
2959
-            )
2960
-            monkeypatch.setenv(
2961
-                "SSH_AUTH_SOCK", "the value doesn't even matter"
2962
-            )
2963
-            monkeypatch.setattr(
2964
-                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"]
2965
-            )
2966
-            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
2967
-            result = runner.invoke(
2968
-                cli.derivepassphrase_vault,
2969
-                ["--key", "--config"],
2970
-                catch_exceptions=False,
2971
-            )
2972
-        assert result.error_exit(
2973
-            error="does not support communicating with it"
2974
-        ), "expected error exit and known error message"
2975
-        assert machinery.warning_emitted(
2976
-            "Cannot connect to an SSH agent via UNIX domain sockets",
2977
-            caplog.record_tuples,
2978
-        ), "expected known warning message in stderr"
... ...
@@ -0,0 +1,2978 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+from __future__ import annotations
6
+
7
+import contextlib
8
+import copy
9
+import errno
10
+import json
11
+import os
12
+import pathlib
13
+import shutil
14
+import socket
15
+import textwrap
16
+import types
17
+from typing import TYPE_CHECKING
18
+
19
+import click.testing
20
+import hypothesis
21
+import pytest
22
+from hypothesis import strategies
23
+from typing_extensions import Any, NamedTuple
24
+
25
+from derivepassphrase import _types, cli, ssh_agent, vault
26
+from derivepassphrase._internals import (
27
+    cli_helpers,
28
+    cli_messages,
29
+)
30
+from tests import data, machinery
31
+from tests.data import callables
32
+from tests.machinery import hypothesis as hypothesis_machinery
33
+from tests.machinery import pytest as pytest_machinery
34
+
35
+if TYPE_CHECKING:
36
+    from typing import NoReturn
37
+
38
+    from typing_extensions import Literal
39
+
40
+DUMMY_SERVICE = data.DUMMY_SERVICE
41
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
42
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
43
+DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
44
+DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
45
+DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW
46
+DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1
47
+
48
+DUMMY_KEY1 = data.DUMMY_KEY1
49
+DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
50
+DUMMY_KEY2 = data.DUMMY_KEY2
51
+DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
52
+DUMMY_KEY3 = data.DUMMY_KEY3
53
+DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64
54
+
55
+TEST_CONFIGS = data.TEST_CONFIGS
56
+
57
+
58
+class IncompatibleConfiguration(NamedTuple):
59
+    other_options: list[tuple[str, ...]]
60
+    needs_service: bool | None
61
+    input: str | None
62
+
63
+
64
+class SingleConfiguration(NamedTuple):
65
+    needs_service: bool | None
66
+    input: str | None
67
+    check_success: bool
68
+
69
+
70
+class OptionCombination(NamedTuple):
71
+    options: list[str]
72
+    incompatible: bool
73
+    needs_service: bool | None
74
+    input: str | None
75
+    check_success: bool
76
+
77
+
78
+PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
79
+    ("--phrase",),
80
+    ("--key",),
81
+    ("--length", "20"),
82
+    ("--repeat", "20"),
83
+    ("--lower", "1"),
84
+    ("--upper", "1"),
85
+    ("--number", "1"),
86
+    ("--space", "1"),
87
+    ("--dash", "1"),
88
+    ("--symbol", "1"),
89
+]
90
+CONFIGURATION_OPTIONS: list[tuple[str, ...]] = [
91
+    ("--notes",),
92
+    ("--config",),
93
+    ("--delete",),
94
+    ("--delete-globals",),
95
+    ("--clear",),
96
+]
97
+CONFIGURATION_COMMANDS: list[tuple[str, ...]] = [
98
+    ("--delete",),
99
+    ("--delete-globals",),
100
+    ("--clear",),
101
+]
102
+STORAGE_OPTIONS: list[tuple[str, ...]] = [("--export", "-"), ("--import", "-")]
103
+INCOMPATIBLE: dict[tuple[str, ...], IncompatibleConfiguration] = {
104
+    ("--phrase",): IncompatibleConfiguration(
105
+        [("--key",), *CONFIGURATION_COMMANDS, *STORAGE_OPTIONS],
106
+        True,
107
+        DUMMY_PASSPHRASE,
108
+    ),
109
+    ("--key",): IncompatibleConfiguration(
110
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
111
+    ),
112
+    ("--length", "20"): IncompatibleConfiguration(
113
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
114
+    ),
115
+    ("--repeat", "20"): IncompatibleConfiguration(
116
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
117
+    ),
118
+    ("--lower", "1"): IncompatibleConfiguration(
119
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
120
+    ),
121
+    ("--upper", "1"): IncompatibleConfiguration(
122
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
123
+    ),
124
+    ("--number", "1"): IncompatibleConfiguration(
125
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
126
+    ),
127
+    ("--space", "1"): IncompatibleConfiguration(
128
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
129
+    ),
130
+    ("--dash", "1"): IncompatibleConfiguration(
131
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
132
+    ),
133
+    ("--symbol", "1"): IncompatibleConfiguration(
134
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, DUMMY_PASSPHRASE
135
+    ),
136
+    ("--notes",): IncompatibleConfiguration(
137
+        CONFIGURATION_COMMANDS + STORAGE_OPTIONS, True, None
138
+    ),
139
+    ("--config", "-p"): IncompatibleConfiguration(
140
+        [("--delete",), ("--delete-globals",), ("--clear",), *STORAGE_OPTIONS],
141
+        None,
142
+        DUMMY_PASSPHRASE,
143
+    ),
144
+    ("--delete",): IncompatibleConfiguration(
145
+        [("--delete-globals",), ("--clear",), *STORAGE_OPTIONS], True, None
146
+    ),
147
+    ("--delete-globals",): IncompatibleConfiguration(
148
+        [("--clear",), *STORAGE_OPTIONS], False, None
149
+    ),
150
+    ("--clear",): IncompatibleConfiguration(STORAGE_OPTIONS, False, None),
151
+    ("--export", "-"): IncompatibleConfiguration(
152
+        [("--import", "-")], False, None
153
+    ),
154
+    ("--import", "-"): IncompatibleConfiguration([], False, None),
155
+}
156
+SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
157
+    ("--phrase",): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
158
+    ("--key",): SingleConfiguration(True, None, False),
159
+    ("--length", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
160
+    ("--repeat", "20"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
161
+    ("--lower", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
162
+    ("--upper", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
163
+    ("--number", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
164
+    ("--space", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
165
+    ("--dash", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
166
+    ("--symbol", "1"): SingleConfiguration(True, DUMMY_PASSPHRASE, True),
167
+    ("--notes",): SingleConfiguration(True, None, False),
168
+    ("--config", "-p"): SingleConfiguration(None, DUMMY_PASSPHRASE, False),
169
+    ("--delete",): SingleConfiguration(True, None, False),
170
+    ("--delete-globals",): SingleConfiguration(False, None, True),
171
+    ("--clear",): SingleConfiguration(False, None, True),
172
+    ("--export", "-"): SingleConfiguration(False, None, True),
173
+    ("--import", "-"): SingleConfiguration(False, '{"services": {}}', True),
174
+}
175
+INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
176
+config: IncompatibleConfiguration | SingleConfiguration
177
+for opt, config in INCOMPATIBLE.items():
178
+    for opt2 in config.other_options:
179
+        INTERESTING_OPTION_COMBINATIONS.extend([
180
+            OptionCombination(
181
+                options=list(opt + opt2),
182
+                incompatible=True,
183
+                needs_service=config.needs_service,
184
+                input=config.input,
185
+                check_success=False,
186
+            ),
187
+            OptionCombination(
188
+                options=list(opt2 + opt),
189
+                incompatible=True,
190
+                needs_service=config.needs_service,
191
+                input=config.input,
192
+                check_success=False,
193
+            ),
194
+        ])
195
+for opt, config in SINGLES.items():
196
+    INTERESTING_OPTION_COMBINATIONS.append(
197
+        OptionCombination(
198
+            options=list(opt),
199
+            incompatible=False,
200
+            needs_service=config.needs_service,
201
+            input=config.input,
202
+            check_success=config.check_success,
203
+        )
204
+    )
205
+
206
+
207
+def is_warning_line(line: str) -> bool:
208
+    """Return true if the line is a warning line."""
209
+    return " Warning: " in line or " Deprecation warning: " in line
210
+
211
+
212
+def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
213
+    """Return true if the warning is harmless, during config import."""
214
+    possible_warnings = [
215
+        "Replacing invalid value ",
216
+        "Removing ineffective setting ",
217
+        (
218
+            "Setting a global passphrase is ineffective "
219
+            "because a key is also set."
220
+        ),
221
+        (
222
+            "Setting a service passphrase is ineffective "
223
+            "because a key is also set:"
224
+        ),
225
+    ]
226
+    return any(
227
+        machinery.warning_emitted(w, [record]) for w in possible_warnings
228
+    )
229
+
230
+
231
+def assert_vault_config_is_indented_and_line_broken(
232
+    config_txt: str,
233
+    /,
234
+) -> None:
235
+    """Return true if the vault configuration is indented and line broken.
236
+
237
+    Indented and rewrapped vault configurations as produced by
238
+    `json.dump` contain the closing '}' of the '$.services' object
239
+    on a separate, indented line:
240
+
241
+    ~~~~
242
+    {
243
+      "services": {
244
+        ...
245
+      }  <-- this brace here
246
+    }
247
+    ~~~~
248
+
249
+    or, if there are no services, then the indented line
250
+
251
+    ~~~~
252
+      "services": {}
253
+    ~~~~
254
+
255
+    Both variations may end with a comma if there are more top-level
256
+    keys.
257
+
258
+    """
259
+    known_indented_lines = {
260
+        "}",
261
+        "},",
262
+        '"services": {}',
263
+        '"services": {},',
264
+    }
265
+    assert any([
266
+        line.strip() in known_indented_lines and line.startswith((" ", "\t"))
267
+        for line in config_txt.splitlines()
268
+    ])
269
+
270
+
271
+class Parametrize(types.SimpleNamespace):
272
+    """Common test parametrizations."""
273
+
274
+    CHARSET_NAME = pytest.mark.parametrize(
275
+        "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
276
+    )
277
+    UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize(
278
+        "command_line",
279
+        [
280
+            pytest.param(
281
+                ["--config", "--phrase"],
282
+                id="configure global passphrase",
283
+            ),
284
+            pytest.param(
285
+                ["--config", "--phrase", "--", "DUMMY_SERVICE"],
286
+                id="configure service passphrase",
287
+            ),
288
+            pytest.param(
289
+                ["--phrase", "--", DUMMY_SERVICE],
290
+                id="interactive passphrase",
291
+            ),
292
+        ],
293
+    )
294
+    CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
295
+        ["command_line", "input", "err_text"],
296
+        [
297
+            pytest.param(
298
+                [],
299
+                "",
300
+                "Cannot update the global settings without any given settings",
301
+                id="None",
302
+            ),
303
+            pytest.param(
304
+                ["--", "sv"],
305
+                "",
306
+                "Cannot update the service-specific settings without any given settings",
307
+                id="None-sv",
308
+            ),
309
+            pytest.param(
310
+                ["--phrase", "--", "sv"],
311
+                "\n",
312
+                "No passphrase was given",
313
+                id="phrase-sv",
314
+            ),
315
+            pytest.param(
316
+                ["--phrase", "--", "sv"],
317
+                "",
318
+                "No passphrase was given",
319
+                id="phrase-sv-eof",
320
+            ),
321
+            pytest.param(
322
+                ["--key"],
323
+                "\n",
324
+                "No SSH key was selected",
325
+                id="key-sv",
326
+            ),
327
+            pytest.param(
328
+                ["--key"],
329
+                "",
330
+                "No SSH key was selected",
331
+                id="key-sv-eof",
332
+            ),
333
+        ],
334
+    )
335
+    CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
336
+        ["command_line", "input", "result_config"],
337
+        [
338
+            pytest.param(
339
+                ["--phrase"],
340
+                "my passphrase\n",
341
+                {"global": {"phrase": "my passphrase"}, "services": {}},
342
+                id="phrase",
343
+            ),
344
+            pytest.param(
345
+                ["--key"],
346
+                "1\n",
347
+                {
348
+                    "global": {"key": DUMMY_KEY1_B64, "phrase": "abc"},
349
+                    "services": {},
350
+                },
351
+                id="key",
352
+            ),
353
+            pytest.param(
354
+                ["--phrase", "--", "sv"],
355
+                "my passphrase\n",
356
+                {
357
+                    "global": {"phrase": "abc"},
358
+                    "services": {"sv": {"phrase": "my passphrase"}},
359
+                },
360
+                id="phrase-sv",
361
+            ),
362
+            pytest.param(
363
+                ["--key", "--", "sv"],
364
+                "1\n",
365
+                {
366
+                    "global": {"phrase": "abc"},
367
+                    "services": {"sv": {"key": DUMMY_KEY1_B64}},
368
+                },
369
+                id="key-sv",
370
+            ),
371
+            pytest.param(
372
+                ["--key", "--length", "15", "--", "sv"],
373
+                "1\n",
374
+                {
375
+                    "global": {"phrase": "abc"},
376
+                    "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
377
+                },
378
+                id="key-length-sv",
379
+            ),
380
+        ],
381
+    )
382
+    BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
383
+        "config",
384
+        [
385
+            pytest.param(
386
+                {
387
+                    "global": {"key": DUMMY_KEY1_B64},
388
+                    "services": {DUMMY_SERVICE: {}},
389
+                },
390
+                id="global_config",
391
+            ),
392
+            pytest.param(
393
+                {"services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}}},
394
+                id="service_config",
395
+            ),
396
+            pytest.param(
397
+                {
398
+                    "global": {"key": DUMMY_KEY1_B64},
399
+                    "services": {DUMMY_SERVICE: {"key": DUMMY_KEY2_B64}},
400
+                },
401
+                id="full_config",
402
+            ),
403
+        ],
404
+    )
405
+    CONFIG_WITH_KEY = pytest.mark.parametrize(
406
+        "config",
407
+        [
408
+            pytest.param(
409
+                {
410
+                    "global": {"key": DUMMY_KEY1_B64},
411
+                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS},
412
+                },
413
+                id="global",
414
+            ),
415
+            pytest.param(
416
+                {
417
+                    "global": {"phrase": DUMMY_PASSPHRASE.rstrip("\n")},
418
+                    "services": {
419
+                        DUMMY_SERVICE: {
420
+                            "key": DUMMY_KEY1_B64,
421
+                            **DUMMY_CONFIG_SETTINGS,
422
+                        }
423
+                    },
424
+                },
425
+                id="service",
426
+            ),
427
+        ],
428
+    )
429
+    VALID_TEST_CONFIGS = pytest.mark.parametrize(
430
+        "config",
431
+        [conf.config for conf in TEST_CONFIGS if conf.is_valid()],
432
+    )
433
+    KEY_OVERRIDING_IN_CONFIG = pytest.mark.parametrize(
434
+        ["config", "command_line"],
435
+        [
436
+            pytest.param(
437
+                {
438
+                    "global": {"key": DUMMY_KEY1_B64},
439
+                    "services": {},
440
+                },
441
+                ["--config", "-p"],
442
+                id="global",
443
+            ),
444
+            pytest.param(
445
+                {
446
+                    "services": {
447
+                        DUMMY_SERVICE: {
448
+                            "key": DUMMY_KEY1_B64,
449
+                            **DUMMY_CONFIG_SETTINGS,
450
+                        },
451
+                    },
452
+                },
453
+                ["--config", "-p", "--", DUMMY_SERVICE],
454
+                id="service",
455
+            ),
456
+            pytest.param(
457
+                {
458
+                    "global": {"key": DUMMY_KEY1_B64},
459
+                    "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()},
460
+                },
461
+                ["--config", "-p", "--", DUMMY_SERVICE],
462
+                id="service-over-global",
463
+            ),
464
+        ],
465
+    )
466
+    NOOP_EDIT_FUNCS = pytest.mark.parametrize(
467
+        ["edit_func_name", "modern_editor_interface"],
468
+        [
469
+            pytest.param("empty", True, id="empty"),
470
+            pytest.param("space", False, id="space-legacy"),
471
+            pytest.param("space", True, id="space-modern"),
472
+        ],
473
+    )
474
+    EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
475
+        "export_options",
476
+        [
477
+            [],
478
+            ["--export-as=sh"],
479
+        ],
480
+    )
481
+    KEY_INDEX = pytest.mark.parametrize(
482
+        "key_index", [1, 2, 3], ids=lambda i: f"index{i}"
483
+    )
484
+    UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
485
+        ["main_config", "command_line", "input", "error_message"],
486
+        [
487
+            pytest.param(
488
+                textwrap.dedent(r"""
489
+                [vault]
490
+                default-unicode-normalization-form = 'XXX'
491
+                """),
492
+                ["--import", "-"],
493
+                json.dumps({
494
+                    "services": {
495
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
496
+                        "with_normalization": {"phrase": "D\u00fcsseldorf"},
497
+                    },
498
+                }),
499
+                (
500
+                    "Invalid value 'XXX' for config key "
501
+                    "vault.default-unicode-normalization-form"
502
+                ),
503
+                id="global",
504
+            ),
505
+            pytest.param(
506
+                textwrap.dedent(r"""
507
+                [vault.unicode-normalization-form]
508
+                with_normalization = 'XXX'
509
+                """),
510
+                ["--import", "-"],
511
+                json.dumps({
512
+                    "services": {
513
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
514
+                        "with_normalization": {"phrase": "D\u00fcsseldorf"},
515
+                    },
516
+                }),
517
+                (
518
+                    "Invalid value 'XXX' for config key "
519
+                    "vault.with_normalization.unicode-normalization-form"
520
+                ),
521
+                id="service",
522
+            ),
523
+        ],
524
+    )
525
+    UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize(
526
+        ["main_config", "command_line", "input", "warning_message"],
527
+        [
528
+            pytest.param(
529
+                "",
530
+                ["--import", "-"],
531
+                json.dumps({
532
+                    "global": {"phrase": "Du\u0308sseldorf"},
533
+                    "services": {},
534
+                }),
535
+                "The $.global passphrase is not NFC-normalized",
536
+                id="global-NFC",
537
+            ),
538
+            pytest.param(
539
+                "",
540
+                ["--import", "-"],
541
+                json.dumps({
542
+                    "services": {
543
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
544
+                        "weird entry name": {"phrase": "Du\u0308sseldorf"},
545
+                    }
546
+                }),
547
+                (
548
+                    'The $.services["weird entry name"] passphrase '
549
+                    "is not NFC-normalized"
550
+                ),
551
+                id="service-weird-name-NFC",
552
+            ),
553
+            pytest.param(
554
+                "",
555
+                ["--config", "-p", "--", DUMMY_SERVICE],
556
+                "Du\u0308sseldorf",
557
+                (
558
+                    f"The $.services.{DUMMY_SERVICE} passphrase "
559
+                    f"is not NFC-normalized"
560
+                ),
561
+                id="config-NFC",
562
+            ),
563
+            pytest.param(
564
+                "",
565
+                ["-p", "--", DUMMY_SERVICE],
566
+                "Du\u0308sseldorf",
567
+                "The interactive input passphrase is not NFC-normalized",
568
+                id="direct-input-NFC",
569
+            ),
570
+            pytest.param(
571
+                textwrap.dedent(r"""
572
+                [vault]
573
+                default-unicode-normalization-form = 'NFD'
574
+                """),
575
+                ["--import", "-"],
576
+                json.dumps({
577
+                    "global": {
578
+                        "phrase": "D\u00fcsseldorf",
579
+                    },
580
+                    "services": {},
581
+                }),
582
+                "The $.global passphrase is not NFD-normalized",
583
+                id="global-NFD",
584
+            ),
585
+            pytest.param(
586
+                textwrap.dedent(r"""
587
+                [vault]
588
+                default-unicode-normalization-form = 'NFD'
589
+                """),
590
+                ["--import", "-"],
591
+                json.dumps({
592
+                    "services": {
593
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
594
+                        "weird entry name": {"phrase": "D\u00fcsseldorf"},
595
+                    },
596
+                }),
597
+                (
598
+                    'The $.services["weird entry name"] passphrase '
599
+                    "is not NFD-normalized"
600
+                ),
601
+                id="service-weird-name-NFD",
602
+            ),
603
+            pytest.param(
604
+                textwrap.dedent(r"""
605
+                [vault.unicode-normalization-form]
606
+                'weird entry name 2' = 'NFKD'
607
+                """),
608
+                ["--import", "-"],
609
+                json.dumps({
610
+                    "services": {
611
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
612
+                        "weird entry name 1": {"phrase": "D\u00fcsseldorf"},
613
+                        "weird entry name 2": {"phrase": "D\u00fcsseldorf"},
614
+                    },
615
+                }),
616
+                (
617
+                    'The $.services["weird entry name 2"] passphrase '
618
+                    "is not NFKD-normalized"
619
+                ),
620
+                id="service-weird-name-2-NFKD",
621
+            ),
622
+        ],
623
+    )
624
+    MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
625
+        "modern_editor_interface", [False, True], ids=["legacy", "modern"]
626
+    )
627
+    NOTES_PLACEMENT = pytest.mark.parametrize(
628
+        ["notes_placement", "placement_args"],
629
+        [
630
+            pytest.param("after", ["--print-notes-after"], id="after"),
631
+            pytest.param("before", ["--print-notes-before"], id="before"),
632
+        ],
633
+    )
634
+    VAULT_CHARSET_OPTION = pytest.mark.parametrize(
635
+        "option",
636
+        [
637
+            "--lower",
638
+            "--upper",
639
+            "--number",
640
+            "--space",
641
+            "--dash",
642
+            "--symbol",
643
+            "--repeat",
644
+            "--length",
645
+        ],
646
+    )
647
+    OPTION_COMBINATIONS_INCOMPATIBLE = pytest.mark.parametrize(
648
+        ["options", "service"],
649
+        [
650
+            pytest.param(o.options, o.needs_service, id=" ".join(o.options))
651
+            for o in INTERESTING_OPTION_COMBINATIONS
652
+            if o.incompatible
653
+        ],
654
+    )
655
+    OPTION_COMBINATIONS_SERVICE_NEEDED = pytest.mark.parametrize(
656
+        ["options", "service", "input", "check_success"],
657
+        [
658
+            pytest.param(
659
+                o.options,
660
+                o.needs_service,
661
+                o.input,
662
+                o.check_success,
663
+                id=" ".join(o.options),
664
+            )
665
+            for o in INTERESTING_OPTION_COMBINATIONS
666
+            if not o.incompatible
667
+        ],
668
+    )
669
+    TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
670
+        "try_race_free_implementation", [True, False]
671
+    )
672
+
673
+
674
+class TestCLI:
675
+    """Tests for the `derivepassphrase vault` command-line interface."""
676
+
677
+    def test_200_help_output(
678
+        self,
679
+    ) -> None:
680
+        """The `--help` option emits help text."""
681
+        runner = machinery.CliRunner(mix_stderr=False)
682
+        # TODO(the-13th-letter): Rewrite using parenthesized
683
+        # with-statements.
684
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
685
+        with contextlib.ExitStack() as stack:
686
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
687
+            stack.enter_context(
688
+                pytest_machinery.isolated_config(
689
+                    monkeypatch=monkeypatch,
690
+                    runner=runner,
691
+                )
692
+            )
693
+            result = runner.invoke(
694
+                cli.derivepassphrase_vault,
695
+                ["--help"],
696
+                catch_exceptions=False,
697
+            )
698
+        assert result.clean_exit(
699
+            empty_stderr=True, output="Passphrase generation:\n"
700
+        ), "expected clean exit, and option groups in help text"
701
+        assert result.clean_exit(
702
+            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
703
+        ), "expected clean exit, and option group epilog in help text"
704
+
705
+    # TODO(the-13th-letter): Remove this test once
706
+    # TestAllCLI.test_202_version_option_output no longer xfails.
707
+    def test_200a_version_output(
708
+        self,
709
+    ) -> None:
710
+        """The `--version` option emits version information."""
711
+        runner = machinery.CliRunner(mix_stderr=False)
712
+        # TODO(the-13th-letter): Rewrite using parenthesized
713
+        # with-statements.
714
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
715
+        with contextlib.ExitStack() as stack:
716
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
717
+            stack.enter_context(
718
+                pytest_machinery.isolated_config(
719
+                    monkeypatch=monkeypatch,
720
+                    runner=runner,
721
+                )
722
+            )
723
+            result = runner.invoke(
724
+                cli.derivepassphrase_vault,
725
+                ["--version"],
726
+                catch_exceptions=False,
727
+            )
728
+        assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), (
729
+            "expected clean exit, and program name in version text"
730
+        )
731
+        assert result.clean_exit(empty_stderr=True, output=cli.VERSION), (
732
+            "expected clean exit, and version in help text"
733
+        )
734
+
735
+    @Parametrize.CHARSET_NAME
736
+    def test_201_disable_character_set(
737
+        self,
738
+        charset_name: str,
739
+    ) -> None:
740
+        """Named character classes can be disabled on the command-line."""
741
+        option = f"--{charset_name}"
742
+        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
743
+        runner = machinery.CliRunner(mix_stderr=False)
744
+        # TODO(the-13th-letter): Rewrite using parenthesized
745
+        # with-statements.
746
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
747
+        with contextlib.ExitStack() as stack:
748
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
749
+            stack.enter_context(
750
+                pytest_machinery.isolated_config(
751
+                    monkeypatch=monkeypatch,
752
+                    runner=runner,
753
+                )
754
+            )
755
+            monkeypatch.setattr(
756
+                cli_helpers,
757
+                "prompt_for_passphrase",
758
+                callables.auto_prompt,
759
+            )
760
+            result = runner.invoke(
761
+                cli.derivepassphrase_vault,
762
+                [option, "0", "-p", "--", DUMMY_SERVICE],
763
+                input=DUMMY_PASSPHRASE,
764
+                catch_exceptions=False,
765
+            )
766
+        assert result.clean_exit(empty_stderr=True), "expected clean exit:"
767
+        for c in charset:
768
+            assert c not in result.stdout, (
769
+                f"derived password contains forbidden character {c!r}"
770
+            )
771
+
772
+    def test_202_disable_repetition(
773
+        self,
774
+    ) -> None:
775
+        """Character repetition can be disabled on the command-line."""
776
+        runner = machinery.CliRunner(mix_stderr=False)
777
+        # TODO(the-13th-letter): Rewrite using parenthesized
778
+        # with-statements.
779
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
780
+        with contextlib.ExitStack() as stack:
781
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
782
+            stack.enter_context(
783
+                pytest_machinery.isolated_config(
784
+                    monkeypatch=monkeypatch,
785
+                    runner=runner,
786
+                )
787
+            )
788
+            monkeypatch.setattr(
789
+                cli_helpers,
790
+                "prompt_for_passphrase",
791
+                callables.auto_prompt,
792
+            )
793
+            result = runner.invoke(
794
+                cli.derivepassphrase_vault,
795
+                ["--repeat", "0", "-p", "--", DUMMY_SERVICE],
796
+                input=DUMMY_PASSPHRASE,
797
+                catch_exceptions=False,
798
+            )
799
+        assert result.clean_exit(empty_stderr=True), (
800
+            "expected clean exit and empty stderr"
801
+        )
802
+        passphrase = result.stdout.rstrip("\r\n")
803
+        for i in range(len(passphrase) - 1):
804
+            assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
805
+                f"derived password contains repeated character "
806
+                f"at position {i}: {result.stdout!r}"
807
+            )
808
+
809
+    @Parametrize.CONFIG_WITH_KEY
810
+    def test_204a_key_from_config(
811
+        self,
812
+        running_ssh_agent: data.RunningSSHAgentInfo,
813
+        config: _types.VaultConfig,
814
+    ) -> None:
815
+        """A stored configured SSH key will be used."""
816
+        del running_ssh_agent
817
+        runner = machinery.CliRunner(mix_stderr=False)
818
+        # TODO(the-13th-letter): Rewrite using parenthesized
819
+        # with-statements.
820
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
821
+        with contextlib.ExitStack() as stack:
822
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
823
+            stack.enter_context(
824
+                pytest_machinery.isolated_vault_config(
825
+                    monkeypatch=monkeypatch,
826
+                    runner=runner,
827
+                    vault_config=config,
828
+                )
829
+            )
830
+            monkeypatch.setattr(
831
+                vault.Vault,
832
+                "phrase_from_key",
833
+                callables.phrase_from_key,
834
+            )
835
+            result = runner.invoke(
836
+                cli.derivepassphrase_vault,
837
+                ["--", DUMMY_SERVICE],
838
+                catch_exceptions=False,
839
+            )
840
+        assert result.clean_exit(empty_stderr=True), (
841
+            "expected clean exit and empty stderr"
842
+        )
843
+        assert result.stdout
844
+        assert (
845
+            result.stdout.rstrip("\n").encode("UTF-8")
846
+            != DUMMY_RESULT_PASSPHRASE
847
+        ), "known false output: phrase-based instead of key-based"
848
+        assert (
849
+            result.stdout.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1
850
+        ), "expected known output"
851
+
852
+    def test_204b_key_from_command_line(
853
+        self,
854
+        running_ssh_agent: data.RunningSSHAgentInfo,
855
+    ) -> None:
856
+        """An SSH key requested on the command-line will be used."""
857
+        del running_ssh_agent
858
+        runner = machinery.CliRunner(mix_stderr=False)
859
+        # TODO(the-13th-letter): Rewrite using parenthesized
860
+        # with-statements.
861
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
862
+        with contextlib.ExitStack() as stack:
863
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
864
+            stack.enter_context(
865
+                pytest_machinery.isolated_vault_config(
866
+                    monkeypatch=monkeypatch,
867
+                    runner=runner,
868
+                    vault_config={
869
+                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
870
+                    },
871
+                )
872
+            )
873
+            monkeypatch.setattr(
874
+                cli_helpers,
875
+                "get_suitable_ssh_keys",
876
+                callables.suitable_ssh_keys,
877
+            )
878
+            monkeypatch.setattr(
879
+                vault.Vault,
880
+                "phrase_from_key",
881
+                callables.phrase_from_key,
882
+            )
883
+            result = runner.invoke(
884
+                cli.derivepassphrase_vault,
885
+                ["-k", "--", DUMMY_SERVICE],
886
+                input="1\n",
887
+                catch_exceptions=False,
888
+            )
889
+        assert result.clean_exit(), "expected clean exit"
890
+        assert result.stdout, "expected program output"
891
+        last_line = result.stdout.splitlines(True)[-1]
892
+        assert (
893
+            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
894
+        ), "known false output: phrase-based instead of key-based"
895
+        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
896
+            "expected known output"
897
+        )
898
+
899
+    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
900
+    @Parametrize.KEY_INDEX
901
+    def test_204c_key_override_on_command_line(
902
+        self,
903
+        running_ssh_agent: data.RunningSSHAgentInfo,
904
+        config: dict[str, Any],
905
+        key_index: int,
906
+    ) -> None:
907
+        """A command-line SSH key will override the configured key."""
908
+        del running_ssh_agent
909
+        runner = machinery.CliRunner(mix_stderr=False)
910
+        # TODO(the-13th-letter): Rewrite using parenthesized
911
+        # with-statements.
912
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
913
+        with contextlib.ExitStack() as stack:
914
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
915
+            stack.enter_context(
916
+                pytest_machinery.isolated_vault_config(
917
+                    monkeypatch=monkeypatch,
918
+                    runner=runner,
919
+                    vault_config=config,
920
+                )
921
+            )
922
+            monkeypatch.setattr(
923
+                ssh_agent.SSHAgentClient,
924
+                "list_keys",
925
+                callables.list_keys,
926
+            )
927
+            monkeypatch.setattr(
928
+                ssh_agent.SSHAgentClient, "sign", callables.sign
929
+            )
930
+            result = runner.invoke(
931
+                cli.derivepassphrase_vault,
932
+                ["-k", "--", DUMMY_SERVICE],
933
+                input=f"{key_index}\n",
934
+            )
935
+        assert result.clean_exit(), "expected clean exit"
936
+        assert result.stdout, "expected program output"
937
+        assert result.stderr, "expected stderr"
938
+        assert "Error:" not in result.stderr, (
939
+            "expected no error messages on stderr"
940
+        )
941
+
942
+    def test_205_service_phrase_if_key_in_global_config(
943
+        self,
944
+        running_ssh_agent: data.RunningSSHAgentInfo,
945
+    ) -> None:
946
+        """A command-line passphrase will override the configured key."""
947
+        del running_ssh_agent
948
+        runner = machinery.CliRunner(mix_stderr=False)
949
+        # TODO(the-13th-letter): Rewrite using parenthesized
950
+        # with-statements.
951
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
952
+        with contextlib.ExitStack() as stack:
953
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
954
+            stack.enter_context(
955
+                pytest_machinery.isolated_vault_config(
956
+                    monkeypatch=monkeypatch,
957
+                    runner=runner,
958
+                    vault_config={
959
+                        "global": {"key": DUMMY_KEY1_B64},
960
+                        "services": {
961
+                            DUMMY_SERVICE: {
962
+                                "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
963
+                                **DUMMY_CONFIG_SETTINGS,
964
+                            }
965
+                        },
966
+                    },
967
+                )
968
+            )
969
+            monkeypatch.setattr(
970
+                ssh_agent.SSHAgentClient,
971
+                "list_keys",
972
+                callables.list_keys,
973
+            )
974
+            monkeypatch.setattr(
975
+                ssh_agent.SSHAgentClient, "sign", callables.sign
976
+            )
977
+            result = runner.invoke(
978
+                cli.derivepassphrase_vault,
979
+                ["--", DUMMY_SERVICE],
980
+                catch_exceptions=False,
981
+            )
982
+        assert result.clean_exit(), "expected clean exit"
983
+        assert result.stdout, "expected program output"
984
+        last_line = result.stdout.splitlines(True)[-1]
985
+        assert (
986
+            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
987
+        ), "known false output: phrase-based instead of key-based"
988
+        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
989
+            "expected known output"
990
+        )
991
+
992
+    @Parametrize.KEY_OVERRIDING_IN_CONFIG
993
+    def test_206_setting_phrase_thus_overriding_key_in_config(
994
+        self,
995
+        running_ssh_agent: data.RunningSSHAgentInfo,
996
+        caplog: pytest.LogCaptureFixture,
997
+        config: _types.VaultConfig,
998
+        command_line: list[str],
999
+    ) -> None:
1000
+        """Configuring a passphrase atop an SSH key works, but warns."""
1001
+        del running_ssh_agent
1002
+        runner = machinery.CliRunner(mix_stderr=False)
1003
+        # TODO(the-13th-letter): Rewrite using parenthesized
1004
+        # with-statements.
1005
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1006
+        with contextlib.ExitStack() as stack:
1007
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1008
+            stack.enter_context(
1009
+                pytest_machinery.isolated_vault_config(
1010
+                    monkeypatch=monkeypatch,
1011
+                    runner=runner,
1012
+                    vault_config=config,
1013
+                )
1014
+            )
1015
+            monkeypatch.setattr(
1016
+                ssh_agent.SSHAgentClient,
1017
+                "list_keys",
1018
+                callables.list_keys,
1019
+            )
1020
+            monkeypatch.setattr(
1021
+                ssh_agent.SSHAgentClient, "sign", callables.sign
1022
+            )
1023
+            result = runner.invoke(
1024
+                cli.derivepassphrase_vault,
1025
+                command_line,
1026
+                input=DUMMY_PASSPHRASE,
1027
+                catch_exceptions=False,
1028
+            )
1029
+        assert result.clean_exit(), "expected clean exit"
1030
+        assert not result.stdout.strip(), "expected no program output"
1031
+        assert result.stderr, "expected known error output"
1032
+        err_lines = result.stderr.splitlines(False)
1033
+        assert err_lines[0].startswith("Passphrase:")
1034
+        assert machinery.warning_emitted(
1035
+            "Setting a service passphrase is ineffective ",
1036
+            caplog.record_tuples,
1037
+        ) or machinery.warning_emitted(
1038
+            "Setting a global passphrase is ineffective ",
1039
+            caplog.record_tuples,
1040
+        ), "expected known warning message"
1041
+        assert all(map(is_warning_line, result.stderr.splitlines(True)))
1042
+        assert all(
1043
+            map(is_harmless_config_import_warning, caplog.record_tuples)
1044
+        ), "unexpected error output"
1045
+
1046
+    @hypothesis.given(
1047
+        notes=strategies.text(
1048
+            strategies.characters(
1049
+                min_codepoint=32,
1050
+                max_codepoint=126,
1051
+                include_characters="\n",
1052
+            ),
1053
+            max_size=256,
1054
+        ),
1055
+    )
1056
+    def test_207_service_with_notes_actually_prints_notes(
1057
+        self,
1058
+        notes: str,
1059
+    ) -> None:
1060
+        """Service notes are printed, if they exist."""
1061
+        hypothesis.assume("Error:" not in notes)
1062
+        runner = machinery.CliRunner(mix_stderr=False)
1063
+        # TODO(the-13th-letter): Rewrite using parenthesized
1064
+        # with-statements.
1065
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1066
+        with contextlib.ExitStack() as stack:
1067
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1068
+            stack.enter_context(
1069
+                pytest_machinery.isolated_vault_config(
1070
+                    monkeypatch=monkeypatch,
1071
+                    runner=runner,
1072
+                    vault_config={
1073
+                        "global": {
1074
+                            "phrase": DUMMY_PASSPHRASE,
1075
+                        },
1076
+                        "services": {
1077
+                            DUMMY_SERVICE: {
1078
+                                "notes": notes,
1079
+                                **DUMMY_CONFIG_SETTINGS,
1080
+                            },
1081
+                        },
1082
+                    },
1083
+                )
1084
+            )
1085
+            result = runner.invoke(
1086
+                cli.derivepassphrase_vault,
1087
+                ["--", DUMMY_SERVICE],
1088
+            )
1089
+        assert result.clean_exit(), "expected clean exit"
1090
+        assert result.stdout, "expected program output"
1091
+        assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode(
1092
+            "ascii"
1093
+        ), "expected known program output"
1094
+        assert result.stderr or not notes.strip(), "expected stderr"
1095
+        assert "Error:" not in result.stderr, (
1096
+            "expected no error messages on stderr"
1097
+        )
1098
+        assert result.stderr.strip() == notes.strip(), (
1099
+            "expected known stderr contents"
1100
+        )
1101
+
1102
+    @Parametrize.VAULT_CHARSET_OPTION
1103
+    def test_210_invalid_argument_range(
1104
+        self,
1105
+        option: str,
1106
+    ) -> None:
1107
+        """Requesting invalidly many characters from a class fails."""
1108
+        runner = machinery.CliRunner(mix_stderr=False)
1109
+        # TODO(the-13th-letter): Rewrite using parenthesized
1110
+        # with-statements.
1111
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1112
+        with contextlib.ExitStack() as stack:
1113
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1114
+            stack.enter_context(
1115
+                pytest_machinery.isolated_config(
1116
+                    monkeypatch=monkeypatch,
1117
+                    runner=runner,
1118
+                )
1119
+            )
1120
+            for value in "-42", "invalid":
1121
+                result = runner.invoke(
1122
+                    cli.derivepassphrase_vault,
1123
+                    [option, value, "-p", "--", DUMMY_SERVICE],
1124
+                    input=DUMMY_PASSPHRASE,
1125
+                    catch_exceptions=False,
1126
+                )
1127
+                assert result.error_exit(error="Invalid value"), (
1128
+                    "expected error exit and known error message"
1129
+                )
1130
+
1131
+    @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
1132
+    def test_211_service_needed(
1133
+        self,
1134
+        options: list[str],
1135
+        service: bool | None,
1136
+        input: str | None,
1137
+        check_success: bool,
1138
+    ) -> None:
1139
+        """We require or forbid a service argument, depending on options."""
1140
+        runner = machinery.CliRunner(mix_stderr=False)
1141
+        # TODO(the-13th-letter): Rewrite using parenthesized
1142
+        # with-statements.
1143
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1144
+        with contextlib.ExitStack() as stack:
1145
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1146
+            stack.enter_context(
1147
+                pytest_machinery.isolated_vault_config(
1148
+                    monkeypatch=monkeypatch,
1149
+                    runner=runner,
1150
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
1151
+                )
1152
+            )
1153
+            monkeypatch.setattr(
1154
+                cli_helpers,
1155
+                "prompt_for_passphrase",
1156
+                callables.auto_prompt,
1157
+            )
1158
+            result = runner.invoke(
1159
+                cli.derivepassphrase_vault,
1160
+                options if service else [*options, "--", DUMMY_SERVICE],
1161
+                input=input,
1162
+                catch_exceptions=False,
1163
+            )
1164
+            if service is not None:
1165
+                err_msg = (
1166
+                    " requires a SERVICE"
1167
+                    if service
1168
+                    else " does not take a SERVICE argument"
1169
+                )
1170
+                assert result.error_exit(error=err_msg), (
1171
+                    "expected error exit and known error message"
1172
+                )
1173
+            else:
1174
+                assert result.clean_exit(empty_stderr=True), (
1175
+                    "expected clean exit"
1176
+                )
1177
+        if check_success:
1178
+            # TODO(the-13th-letter): Rewrite using parenthesized
1179
+            # with-statements.
1180
+            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1181
+            with contextlib.ExitStack() as stack:
1182
+                monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1183
+                stack.enter_context(
1184
+                    pytest_machinery.isolated_vault_config(
1185
+                        monkeypatch=monkeypatch,
1186
+                        runner=runner,
1187
+                        vault_config={
1188
+                            "global": {"phrase": "abc"},
1189
+                            "services": {},
1190
+                        },
1191
+                    )
1192
+                )
1193
+                monkeypatch.setattr(
1194
+                    cli_helpers,
1195
+                    "prompt_for_passphrase",
1196
+                    callables.auto_prompt,
1197
+                )
1198
+                result = runner.invoke(
1199
+                    cli.derivepassphrase_vault,
1200
+                    [*options, "--", DUMMY_SERVICE] if service else options,
1201
+                    input=input,
1202
+                    catch_exceptions=False,
1203
+                )
1204
+            assert result.clean_exit(empty_stderr=True), "expected clean exit"
1205
+
1206
+    def test_211a_empty_service_name_causes_warning(
1207
+        self,
1208
+        caplog: pytest.LogCaptureFixture,
1209
+    ) -> None:
1210
+        """Using an empty service name (where permissible) warns.
1211
+
1212
+        Only the `--config` option can optionally take a service name.
1213
+
1214
+        """
1215
+
1216
+        def is_expected_warning(record: tuple[str, int, str]) -> bool:
1217
+            return is_harmless_config_import_warning(
1218
+                record
1219
+            ) or machinery.warning_emitted(
1220
+                "An empty SERVICE is not supported by vault(1)", [record]
1221
+            )
1222
+
1223
+        runner = machinery.CliRunner(mix_stderr=False)
1224
+        # TODO(the-13th-letter): Rewrite using parenthesized
1225
+        # with-statements.
1226
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1227
+        with contextlib.ExitStack() as stack:
1228
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1229
+            stack.enter_context(
1230
+                pytest_machinery.isolated_vault_config(
1231
+                    monkeypatch=monkeypatch,
1232
+                    runner=runner,
1233
+                    vault_config={"services": {}},
1234
+                )
1235
+            )
1236
+            monkeypatch.setattr(
1237
+                cli_helpers,
1238
+                "prompt_for_passphrase",
1239
+                callables.auto_prompt,
1240
+            )
1241
+            result = runner.invoke(
1242
+                cli.derivepassphrase_vault,
1243
+                ["--config", "--length=30", "--", ""],
1244
+                catch_exceptions=False,
1245
+            )
1246
+            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1247
+            assert result.stderr is not None, "expected known error output"
1248
+            assert all(map(is_expected_warning, caplog.record_tuples)), (
1249
+                "expected known error output"
1250
+            )
1251
+            assert cli_helpers.load_config() == {
1252
+                "global": {"length": 30},
1253
+                "services": {},
1254
+            }, "requested configuration change was not applied"
1255
+            caplog.clear()
1256
+            result = runner.invoke(
1257
+                cli.derivepassphrase_vault,
1258
+                ["--import", "-"],
1259
+                input=json.dumps({"services": {"": {"length": 40}}}),
1260
+                catch_exceptions=False,
1261
+            )
1262
+            assert result.clean_exit(empty_stderr=False), "expected clean exit"
1263
+            assert result.stderr is not None, "expected known error output"
1264
+            assert all(map(is_expected_warning, caplog.record_tuples)), (
1265
+                "expected known error output"
1266
+            )
1267
+            assert cli_helpers.load_config() == {
1268
+                "global": {"length": 30},
1269
+                "services": {"": {"length": 40}},
1270
+            }, "requested configuration change was not applied"
1271
+
1272
+    @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
1273
+    def test_212_incompatible_options(
1274
+        self,
1275
+        options: list[str],
1276
+        service: bool | None,
1277
+    ) -> None:
1278
+        """Incompatible options are detected."""
1279
+        runner = machinery.CliRunner(mix_stderr=False)
1280
+        # TODO(the-13th-letter): Rewrite using parenthesized
1281
+        # with-statements.
1282
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1283
+        with contextlib.ExitStack() as stack:
1284
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1285
+            stack.enter_context(
1286
+                pytest_machinery.isolated_config(
1287
+                    monkeypatch=monkeypatch,
1288
+                    runner=runner,
1289
+                )
1290
+            )
1291
+            result = runner.invoke(
1292
+                cli.derivepassphrase_vault,
1293
+                [*options, "--", DUMMY_SERVICE] if service else options,
1294
+                input=DUMMY_PASSPHRASE,
1295
+                catch_exceptions=False,
1296
+            )
1297
+        assert result.error_exit(error="mutually exclusive with "), (
1298
+            "expected error exit and known error message"
1299
+        )
1300
+
1301
+    @Parametrize.VALID_TEST_CONFIGS
1302
+    def test_213_import_config_success(
1303
+        self,
1304
+        caplog: pytest.LogCaptureFixture,
1305
+        config: Any,
1306
+    ) -> None:
1307
+        """Importing a configuration works."""
1308
+        runner = machinery.CliRunner(mix_stderr=False)
1309
+        # TODO(the-13th-letter): Rewrite using parenthesized
1310
+        # with-statements.
1311
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1312
+        with contextlib.ExitStack() as stack:
1313
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1314
+            stack.enter_context(
1315
+                pytest_machinery.isolated_vault_config(
1316
+                    monkeypatch=monkeypatch,
1317
+                    runner=runner,
1318
+                    vault_config={"services": {}},
1319
+                )
1320
+            )
1321
+            result = runner.invoke(
1322
+                cli.derivepassphrase_vault,
1323
+                ["--import", "-"],
1324
+                input=json.dumps(config),
1325
+                catch_exceptions=False,
1326
+            )
1327
+            config_txt = cli_helpers.config_filename(
1328
+                subsystem="vault"
1329
+            ).read_text(encoding="UTF-8")
1330
+            config2 = json.loads(config_txt)
1331
+        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1332
+        assert config2 == config, "config not imported correctly"
1333
+        assert not result.stderr or all(  # pragma: no branch
1334
+            map(is_harmless_config_import_warning, caplog.record_tuples)
1335
+        ), "unexpected error output"
1336
+        assert_vault_config_is_indented_and_line_broken(config_txt)
1337
+
1338
+    @hypothesis.settings(
1339
+        suppress_health_check=[
1340
+            *hypothesis.settings().suppress_health_check,
1341
+            hypothesis.HealthCheck.function_scoped_fixture,
1342
+        ],
1343
+    )
1344
+    @hypothesis.given(
1345
+        conf=hypothesis_machinery.smudged_vault_test_config(
1346
+            strategies.sampled_from([
1347
+                conf for conf in data.TEST_CONFIGS if conf.is_valid()
1348
+            ])
1349
+        )
1350
+    )
1351
+    def test_213a_import_config_success(
1352
+        self,
1353
+        caplog: pytest.LogCaptureFixture,
1354
+        conf: data.VaultTestConfig,
1355
+    ) -> None:
1356
+        """Importing a smudged configuration works.
1357
+
1358
+        Tested via hypothesis.
1359
+
1360
+        """
1361
+        config = conf.config
1362
+        config2 = copy.deepcopy(config)
1363
+        _types.clean_up_falsy_vault_config_values(config2)
1364
+        # Reset caplog between hypothesis runs.
1365
+        caplog.clear()
1366
+        runner = machinery.CliRunner(mix_stderr=False)
1367
+        # TODO(the-13th-letter): Rewrite using parenthesized
1368
+        # with-statements.
1369
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1370
+        with contextlib.ExitStack() as stack:
1371
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1372
+            stack.enter_context(
1373
+                pytest_machinery.isolated_vault_config(
1374
+                    monkeypatch=monkeypatch,
1375
+                    runner=runner,
1376
+                    vault_config={"services": {}},
1377
+                )
1378
+            )
1379
+            result = runner.invoke(
1380
+                cli.derivepassphrase_vault,
1381
+                ["--import", "-"],
1382
+                input=json.dumps(config),
1383
+                catch_exceptions=False,
1384
+            )
1385
+            config_txt = cli_helpers.config_filename(
1386
+                subsystem="vault"
1387
+            ).read_text(encoding="UTF-8")
1388
+            config3 = json.loads(config_txt)
1389
+        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1390
+        assert config3 == config2, "config not imported correctly"
1391
+        assert not result.stderr or all(
1392
+            map(is_harmless_config_import_warning, caplog.record_tuples)
1393
+        ), "unexpected error output"
1394
+        assert_vault_config_is_indented_and_line_broken(config_txt)
1395
+
1396
+    def test_213b_import_bad_config_not_vault_config(
1397
+        self,
1398
+    ) -> None:
1399
+        """Importing an invalid config fails."""
1400
+        runner = machinery.CliRunner(mix_stderr=False)
1401
+        # TODO(the-13th-letter): Rewrite using parenthesized
1402
+        # with-statements.
1403
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1404
+        with contextlib.ExitStack() as stack:
1405
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1406
+            stack.enter_context(
1407
+                pytest_machinery.isolated_config(
1408
+                    monkeypatch=monkeypatch,
1409
+                    runner=runner,
1410
+                )
1411
+            )
1412
+            result = runner.invoke(
1413
+                cli.derivepassphrase_vault,
1414
+                ["--import", "-"],
1415
+                input="null",
1416
+                catch_exceptions=False,
1417
+            )
1418
+        assert result.error_exit(error="Invalid vault config"), (
1419
+            "expected error exit and known error message"
1420
+        )
1421
+
1422
+    def test_213c_import_bad_config_not_json_data(
1423
+        self,
1424
+    ) -> None:
1425
+        """Importing an invalid config fails."""
1426
+        runner = machinery.CliRunner(mix_stderr=False)
1427
+        # TODO(the-13th-letter): Rewrite using parenthesized
1428
+        # with-statements.
1429
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1430
+        with contextlib.ExitStack() as stack:
1431
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1432
+            stack.enter_context(
1433
+                pytest_machinery.isolated_config(
1434
+                    monkeypatch=monkeypatch,
1435
+                    runner=runner,
1436
+                )
1437
+            )
1438
+            result = runner.invoke(
1439
+                cli.derivepassphrase_vault,
1440
+                ["--import", "-"],
1441
+                input="This string is not valid JSON.",
1442
+                catch_exceptions=False,
1443
+            )
1444
+        assert result.error_exit(error="cannot decode JSON"), (
1445
+            "expected error exit and known error message"
1446
+        )
1447
+
1448
+    def test_213d_import_bad_config_not_a_file(
1449
+        self,
1450
+    ) -> None:
1451
+        """Importing an invalid config fails."""
1452
+        runner = machinery.CliRunner(mix_stderr=False)
1453
+        # `isolated_vault_config` ensures the configuration is valid
1454
+        # JSON.  So, to pass an actual broken configuration, we must
1455
+        # open the configuration file ourselves afterwards, inside the
1456
+        # context.
1457
+        #
1458
+        # TODO(the-13th-letter): Rewrite using parenthesized
1459
+        # with-statements.
1460
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1461
+        with contextlib.ExitStack() as stack:
1462
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1463
+            stack.enter_context(
1464
+                pytest_machinery.isolated_vault_config(
1465
+                    monkeypatch=monkeypatch,
1466
+                    runner=runner,
1467
+                    vault_config={"services": {}},
1468
+                )
1469
+            )
1470
+            cli_helpers.config_filename(subsystem="vault").write_text(
1471
+                "This string is not valid JSON.\n", encoding="UTF-8"
1472
+            )
1473
+            dname = cli_helpers.config_filename(subsystem=None)
1474
+            result = runner.invoke(
1475
+                cli.derivepassphrase_vault,
1476
+                ["--import", os.fsdecode(dname)],
1477
+                catch_exceptions=False,
1478
+            )
1479
+        # The Annoying OS uses EACCES, other OSes use EISDIR.
1480
+        assert result.error_exit(
1481
+            error=os.strerror(errno.EISDIR)
1482
+        ) or result.error_exit(error=os.strerror(errno.EACCES)), (
1483
+            "expected error exit and known error message"
1484
+        )
1485
+
1486
+    @Parametrize.VALID_TEST_CONFIGS
1487
+    def test_214_export_config_success(
1488
+        self,
1489
+        caplog: pytest.LogCaptureFixture,
1490
+        config: Any,
1491
+    ) -> None:
1492
+        """Exporting a configuration works."""
1493
+        runner = machinery.CliRunner(mix_stderr=False)
1494
+        # TODO(the-13th-letter): Rewrite using parenthesized
1495
+        # with-statements.
1496
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1497
+        with contextlib.ExitStack() as stack:
1498
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1499
+            stack.enter_context(
1500
+                pytest_machinery.isolated_vault_config(
1501
+                    monkeypatch=monkeypatch,
1502
+                    runner=runner,
1503
+                    vault_config=config,
1504
+                )
1505
+            )
1506
+            with cli_helpers.config_filename(subsystem="vault").open(
1507
+                "w", encoding="UTF-8"
1508
+            ) as outfile:
1509
+                # Ensure the config is written on one line.
1510
+                json.dump(config, outfile, indent=None)
1511
+            result = runner.invoke(
1512
+                cli.derivepassphrase_vault,
1513
+                ["--export", "-"],
1514
+                catch_exceptions=False,
1515
+            )
1516
+            with cli_helpers.config_filename(subsystem="vault").open(
1517
+                encoding="UTF-8"
1518
+            ) as infile:
1519
+                config2 = json.load(infile)
1520
+        assert result.clean_exit(empty_stderr=False), "expected clean exit"
1521
+        assert config2 == config, "config not imported correctly"
1522
+        assert not result.stderr or all(  # pragma: no branch
1523
+            map(is_harmless_config_import_warning, caplog.record_tuples)
1524
+        ), "unexpected error output"
1525
+        assert_vault_config_is_indented_and_line_broken(result.stdout)
1526
+
1527
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1528
+    def test_214a_export_settings_no_stored_settings(
1529
+        self,
1530
+        export_options: list[str],
1531
+    ) -> None:
1532
+        """Exporting the default, empty config works."""
1533
+        runner = machinery.CliRunner(mix_stderr=False)
1534
+        # TODO(the-13th-letter): Rewrite using parenthesized
1535
+        # with-statements.
1536
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1537
+        with contextlib.ExitStack() as stack:
1538
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1539
+            stack.enter_context(
1540
+                pytest_machinery.isolated_config(
1541
+                    monkeypatch=monkeypatch,
1542
+                    runner=runner,
1543
+                )
1544
+            )
1545
+            cli_helpers.config_filename(subsystem="vault").unlink(
1546
+                missing_ok=True
1547
+            )
1548
+            result = runner.invoke(
1549
+                # Test parent context navigation by not calling
1550
+                # `cli.derivepassphrase_vault` directly.  Used e.g. in
1551
+                # the `--export-as=sh` section to autoconstruct the
1552
+                # program name correctly.
1553
+                cli.derivepassphrase,
1554
+                ["vault", "--export", "-", *export_options],
1555
+                catch_exceptions=False,
1556
+            )
1557
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
1558
+
1559
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1560
+    def test_214b_export_settings_bad_stored_config(
1561
+        self,
1562
+        export_options: list[str],
1563
+    ) -> None:
1564
+        """Exporting an invalid config fails."""
1565
+        runner = machinery.CliRunner(mix_stderr=False)
1566
+        # TODO(the-13th-letter): Rewrite using parenthesized
1567
+        # with-statements.
1568
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1569
+        with contextlib.ExitStack() as stack:
1570
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1571
+            stack.enter_context(
1572
+                pytest_machinery.isolated_vault_config(
1573
+                    monkeypatch=monkeypatch,
1574
+                    runner=runner,
1575
+                    vault_config={},
1576
+                )
1577
+            )
1578
+            result = runner.invoke(
1579
+                cli.derivepassphrase_vault,
1580
+                ["--export", "-", *export_options],
1581
+                input="null",
1582
+                catch_exceptions=False,
1583
+            )
1584
+        assert result.error_exit(error="Cannot load vault settings:"), (
1585
+            "expected error exit and known error message"
1586
+        )
1587
+
1588
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1589
+    def test_214c_export_settings_not_a_file(
1590
+        self,
1591
+        export_options: list[str],
1592
+    ) -> None:
1593
+        """Exporting an invalid config fails."""
1594
+        runner = machinery.CliRunner(mix_stderr=False)
1595
+        # TODO(the-13th-letter): Rewrite using parenthesized
1596
+        # with-statements.
1597
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1598
+        with contextlib.ExitStack() as stack:
1599
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1600
+            stack.enter_context(
1601
+                pytest_machinery.isolated_config(
1602
+                    monkeypatch=monkeypatch,
1603
+                    runner=runner,
1604
+                )
1605
+            )
1606
+            config_file = cli_helpers.config_filename(subsystem="vault")
1607
+            config_file.unlink(missing_ok=True)
1608
+            config_file.mkdir(parents=True, exist_ok=True)
1609
+            result = runner.invoke(
1610
+                cli.derivepassphrase_vault,
1611
+                ["--export", "-", *export_options],
1612
+                input="null",
1613
+                catch_exceptions=False,
1614
+            )
1615
+        assert result.error_exit(error="Cannot load vault settings:"), (
1616
+            "expected error exit and known error message"
1617
+        )
1618
+
1619
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1620
+    def test_214d_export_settings_target_not_a_file(
1621
+        self,
1622
+        export_options: list[str],
1623
+    ) -> None:
1624
+        """Exporting an invalid config fails."""
1625
+        runner = machinery.CliRunner(mix_stderr=False)
1626
+        # TODO(the-13th-letter): Rewrite using parenthesized
1627
+        # with-statements.
1628
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1629
+        with contextlib.ExitStack() as stack:
1630
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1631
+            stack.enter_context(
1632
+                pytest_machinery.isolated_config(
1633
+                    monkeypatch=monkeypatch,
1634
+                    runner=runner,
1635
+                )
1636
+            )
1637
+            dname = cli_helpers.config_filename(subsystem=None)
1638
+            result = runner.invoke(
1639
+                cli.derivepassphrase_vault,
1640
+                ["--export", os.fsdecode(dname), *export_options],
1641
+                input="null",
1642
+                catch_exceptions=False,
1643
+            )
1644
+        assert result.error_exit(error="Cannot export vault settings:"), (
1645
+            "expected error exit and known error message"
1646
+        )
1647
+
1648
+    @pytest_machinery.skip_if_on_the_annoying_os
1649
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1650
+    def test_214e_export_settings_settings_directory_not_a_directory(
1651
+        self,
1652
+        export_options: list[str],
1653
+    ) -> None:
1654
+        """Exporting an invalid config fails."""
1655
+        runner = machinery.CliRunner(mix_stderr=False)
1656
+        # TODO(the-13th-letter): Rewrite using parenthesized
1657
+        # with-statements.
1658
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1659
+        with contextlib.ExitStack() as stack:
1660
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1661
+            stack.enter_context(
1662
+                pytest_machinery.isolated_config(
1663
+                    monkeypatch=monkeypatch,
1664
+                    runner=runner,
1665
+                )
1666
+            )
1667
+            config_dir = cli_helpers.config_filename(subsystem=None)
1668
+            with contextlib.suppress(FileNotFoundError):
1669
+                shutil.rmtree(config_dir)
1670
+            config_dir.write_text("Obstruction!!\n")
1671
+            result = runner.invoke(
1672
+                cli.derivepassphrase_vault,
1673
+                ["--export", "-", *export_options],
1674
+                input="null",
1675
+                catch_exceptions=False,
1676
+            )
1677
+        assert result.error_exit(
1678
+            error="Cannot load vault settings:"
1679
+        ) or result.error_exit(error="Cannot load user config:"), (
1680
+            "expected error exit and known error message"
1681
+        )
1682
+
1683
+    @Parametrize.NOTES_PLACEMENT
1684
+    @hypothesis.given(
1685
+        notes=strategies.text(
1686
+            strategies.characters(
1687
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
1688
+            ),
1689
+            min_size=1,
1690
+            max_size=512,
1691
+        ).filter(str.strip),
1692
+    )
1693
+    def test_215_notes_placement(
1694
+        self,
1695
+        notes_placement: Literal["before", "after"],
1696
+        placement_args: list[str],
1697
+        notes: str,
1698
+    ) -> None:
1699
+        notes = notes.strip()
1700
+        maybe_notes = {"notes": notes} if notes else {}
1701
+        vault_config = {
1702
+            "global": {"phrase": DUMMY_PASSPHRASE},
1703
+            "services": {
1704
+                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
1705
+            },
1706
+        }
1707
+        result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii")
1708
+        expected = (
1709
+            f"{notes}\n\n{result_phrase}\n"
1710
+            if notes_placement == "before"
1711
+            else f"{result_phrase}\n\n{notes}\n\n"
1712
+        )
1713
+        runner = machinery.CliRunner(mix_stderr=True)
1714
+        # TODO(the-13th-letter): Rewrite using parenthesized
1715
+        # with-statements.
1716
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1717
+        with contextlib.ExitStack() as stack:
1718
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1719
+            stack.enter_context(
1720
+                pytest_machinery.isolated_vault_config(
1721
+                    monkeypatch=monkeypatch,
1722
+                    runner=runner,
1723
+                    vault_config=vault_config,
1724
+                )
1725
+            )
1726
+            result = runner.invoke(
1727
+                cli.derivepassphrase_vault,
1728
+                [*placement_args, "--", DUMMY_SERVICE],
1729
+                catch_exceptions=False,
1730
+            )
1731
+            assert result.clean_exit(output=expected), "expected clean exit"
1732
+
1733
+    @Parametrize.MODERN_EDITOR_INTERFACE
1734
+    @hypothesis.settings(
1735
+        suppress_health_check=[
1736
+            *hypothesis.settings().suppress_health_check,
1737
+            hypothesis.HealthCheck.function_scoped_fixture,
1738
+        ],
1739
+    )
1740
+    @hypothesis.given(
1741
+        notes=strategies.text(
1742
+            strategies.characters(
1743
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
1744
+            ),
1745
+            min_size=1,
1746
+            max_size=512,
1747
+        ).filter(str.strip),
1748
+    )
1749
+    def test_220_edit_notes_successfully(
1750
+        self,
1751
+        caplog: pytest.LogCaptureFixture,
1752
+        modern_editor_interface: bool,
1753
+        notes: str,
1754
+    ) -> None:
1755
+        """Editing notes works."""
1756
+        marker = cli_messages.TranslatedString(
1757
+            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1758
+        )
1759
+        edit_result = f"""
1760
+
1761
+{marker}
1762
+{notes}
1763
+"""
1764
+        # Reset caplog between hypothesis runs.
1765
+        caplog.clear()
1766
+        runner = machinery.CliRunner(mix_stderr=False)
1767
+        # TODO(the-13th-letter): Rewrite using parenthesized
1768
+        # with-statements.
1769
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1770
+        with contextlib.ExitStack() as stack:
1771
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1772
+            stack.enter_context(
1773
+                pytest_machinery.isolated_vault_config(
1774
+                    monkeypatch=monkeypatch,
1775
+                    runner=runner,
1776
+                    vault_config={
1777
+                        "global": {"phrase": "abc"},
1778
+                        "services": {"sv": {"notes": "Contents go here"}},
1779
+                    },
1780
+                )
1781
+            )
1782
+            notes_backup_file = cli_helpers.config_filename(
1783
+                subsystem="notes backup"
1784
+            )
1785
+            notes_backup_file.write_text(
1786
+                "These backup notes are left over from the previous session.",
1787
+                encoding="UTF-8",
1788
+            )
1789
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result)
1790
+            result = runner.invoke(
1791
+                cli.derivepassphrase_vault,
1792
+                [
1793
+                    "--config",
1794
+                    "--notes",
1795
+                    "--modern-editor-interface"
1796
+                    if modern_editor_interface
1797
+                    else "--vault-legacy-editor-interface",
1798
+                    "--",
1799
+                    "sv",
1800
+                ],
1801
+                catch_exceptions=False,
1802
+            )
1803
+            assert result.clean_exit(), "expected clean exit"
1804
+            assert all(map(is_warning_line, result.stderr.splitlines(True)))
1805
+            assert modern_editor_interface or machinery.warning_emitted(
1806
+                "A backup copy of the old notes was saved",
1807
+                caplog.record_tuples,
1808
+            ), "expected known warning message in stderr"
1809
+            assert (
1810
+                modern_editor_interface
1811
+                or notes_backup_file.read_text(encoding="UTF-8")
1812
+                == "Contents go here"
1813
+            )
1814
+            with cli_helpers.config_filename(subsystem="vault").open(
1815
+                encoding="UTF-8"
1816
+            ) as infile:
1817
+                config = json.load(infile)
1818
+            assert config == {
1819
+                "global": {"phrase": "abc"},
1820
+                "services": {
1821
+                    "sv": {
1822
+                        "notes": notes.strip()
1823
+                        if modern_editor_interface
1824
+                        else edit_result.strip()
1825
+                    }
1826
+                },
1827
+            }
1828
+
1829
+    @Parametrize.NOOP_EDIT_FUNCS
1830
+    @hypothesis.given(
1831
+        notes=strategies.text(
1832
+            strategies.characters(
1833
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
1834
+            ),
1835
+            min_size=1,
1836
+            max_size=512,
1837
+        ).filter(str.strip),
1838
+    )
1839
+    def test_221_edit_notes_noop(
1840
+        self,
1841
+        edit_func_name: Literal["empty", "space"],
1842
+        modern_editor_interface: bool,
1843
+        notes: str,
1844
+    ) -> None:
1845
+        """Abandoning edited notes works."""
1846
+
1847
+        def empty(text: str, *_args: Any, **_kwargs: Any) -> str:
1848
+            del text
1849
+            return ""
1850
+
1851
+        def space(text: str, *_args: Any, **_kwargs: Any) -> str:
1852
+            del text
1853
+            return "       " + notes.strip() + "\n\n\n\n\n\n"
1854
+
1855
+        edit_funcs = {"empty": empty, "space": space}
1856
+        runner = machinery.CliRunner(mix_stderr=False)
1857
+        # TODO(the-13th-letter): Rewrite using parenthesized
1858
+        # with-statements.
1859
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1860
+        with contextlib.ExitStack() as stack:
1861
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1862
+            stack.enter_context(
1863
+                pytest_machinery.isolated_vault_config(
1864
+                    monkeypatch=monkeypatch,
1865
+                    runner=runner,
1866
+                    vault_config={
1867
+                        "global": {"phrase": "abc"},
1868
+                        "services": {"sv": {"notes": notes.strip()}},
1869
+                    },
1870
+                )
1871
+            )
1872
+            notes_backup_file = cli_helpers.config_filename(
1873
+                subsystem="notes backup"
1874
+            )
1875
+            notes_backup_file.write_text(
1876
+                "These backup notes are left over from the previous session.",
1877
+                encoding="UTF-8",
1878
+            )
1879
+            monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name])
1880
+            result = runner.invoke(
1881
+                cli.derivepassphrase_vault,
1882
+                [
1883
+                    "--config",
1884
+                    "--notes",
1885
+                    "--modern-editor-interface"
1886
+                    if modern_editor_interface
1887
+                    else "--vault-legacy-editor-interface",
1888
+                    "--",
1889
+                    "sv",
1890
+                ],
1891
+                catch_exceptions=False,
1892
+            )
1893
+            assert result.clean_exit(empty_stderr=True) or result.error_exit(
1894
+                error="the user aborted the request"
1895
+            ), "expected clean exit"
1896
+            assert (
1897
+                modern_editor_interface
1898
+                or notes_backup_file.read_text(encoding="UTF-8")
1899
+                == "These backup notes are left over from the previous session."
1900
+            )
1901
+            with cli_helpers.config_filename(subsystem="vault").open(
1902
+                encoding="UTF-8"
1903
+            ) as infile:
1904
+                config = json.load(infile)
1905
+            assert config == {
1906
+                "global": {"phrase": "abc"},
1907
+                "services": {"sv": {"notes": notes.strip()}},
1908
+            }
1909
+
1910
+    # TODO(the-13th-letter): Keep this behavior or not, with or without
1911
+    # warning?
1912
+    @Parametrize.MODERN_EDITOR_INTERFACE
1913
+    @hypothesis.settings(
1914
+        suppress_health_check=[
1915
+            *hypothesis.settings().suppress_health_check,
1916
+            hypothesis.HealthCheck.function_scoped_fixture,
1917
+        ],
1918
+    )
1919
+    @hypothesis.given(
1920
+        notes=strategies.text(
1921
+            strategies.characters(
1922
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
1923
+            ),
1924
+            min_size=1,
1925
+            max_size=512,
1926
+        ).filter(str.strip),
1927
+    )
1928
+    def test_222_edit_notes_marker_removed(
1929
+        self,
1930
+        caplog: pytest.LogCaptureFixture,
1931
+        modern_editor_interface: bool,
1932
+        notes: str,
1933
+    ) -> None:
1934
+        """Removing the notes marker still saves the notes.
1935
+
1936
+        TODO: Keep this behavior or not, with or without warning?
1937
+
1938
+        """
1939
+        notes_marker = cli_messages.TranslatedString(
1940
+            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1941
+        )
1942
+        hypothesis.assume(str(notes_marker) not in notes.strip())
1943
+        # Reset caplog between hypothesis runs.
1944
+        caplog.clear()
1945
+        runner = machinery.CliRunner(mix_stderr=False)
1946
+        # TODO(the-13th-letter): Rewrite using parenthesized
1947
+        # with-statements.
1948
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1949
+        with contextlib.ExitStack() as stack:
1950
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1951
+            stack.enter_context(
1952
+                pytest_machinery.isolated_vault_config(
1953
+                    monkeypatch=monkeypatch,
1954
+                    runner=runner,
1955
+                    vault_config={
1956
+                        "global": {"phrase": "abc"},
1957
+                        "services": {"sv": {"notes": "Contents go here"}},
1958
+                    },
1959
+                )
1960
+            )
1961
+            notes_backup_file = cli_helpers.config_filename(
1962
+                subsystem="notes backup"
1963
+            )
1964
+            notes_backup_file.write_text(
1965
+                "These backup notes are left over from the previous session.",
1966
+                encoding="UTF-8",
1967
+            )
1968
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: notes)
1969
+            result = runner.invoke(
1970
+                cli.derivepassphrase_vault,
1971
+                [
1972
+                    "--config",
1973
+                    "--notes",
1974
+                    "--modern-editor-interface"
1975
+                    if modern_editor_interface
1976
+                    else "--vault-legacy-editor-interface",
1977
+                    "--",
1978
+                    "sv",
1979
+                ],
1980
+                catch_exceptions=False,
1981
+            )
1982
+            assert result.clean_exit(), "expected clean exit"
1983
+            assert not result.stderr or all(
1984
+                map(is_warning_line, result.stderr.splitlines(True))
1985
+            )
1986
+            assert not caplog.record_tuples or machinery.warning_emitted(
1987
+                "A backup copy of the old notes was saved",
1988
+                caplog.record_tuples,
1989
+            ), "expected known warning message in stderr"
1990
+            assert (
1991
+                modern_editor_interface
1992
+                or notes_backup_file.read_text(encoding="UTF-8")
1993
+                == "Contents go here"
1994
+            )
1995
+            with cli_helpers.config_filename(subsystem="vault").open(
1996
+                encoding="UTF-8"
1997
+            ) as infile:
1998
+                config = json.load(infile)
1999
+            assert config == {
2000
+                "global": {"phrase": "abc"},
2001
+                "services": {"sv": {"notes": notes.strip()}},
2002
+            }
2003
+
2004
+    @hypothesis.given(
2005
+        notes=strategies.text(
2006
+            strategies.characters(
2007
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
2008
+            ),
2009
+            min_size=1,
2010
+            max_size=512,
2011
+        ).filter(str.strip),
2012
+    )
2013
+    def test_223_edit_notes_abort(
2014
+        self,
2015
+        notes: str,
2016
+    ) -> None:
2017
+        """Aborting editing notes works.
2018
+
2019
+        Aborting is only supported with the modern editor interface.
2020
+
2021
+        """
2022
+        runner = machinery.CliRunner(mix_stderr=False)
2023
+        # TODO(the-13th-letter): Rewrite using parenthesized
2024
+        # with-statements.
2025
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2026
+        with contextlib.ExitStack() as stack:
2027
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2028
+            stack.enter_context(
2029
+                pytest_machinery.isolated_vault_config(
2030
+                    monkeypatch=monkeypatch,
2031
+                    runner=runner,
2032
+                    vault_config={
2033
+                        "global": {"phrase": "abc"},
2034
+                        "services": {"sv": {"notes": notes.strip()}},
2035
+                    },
2036
+                )
2037
+            )
2038
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
2039
+            result = runner.invoke(
2040
+                cli.derivepassphrase_vault,
2041
+                [
2042
+                    "--config",
2043
+                    "--notes",
2044
+                    "--modern-editor-interface",
2045
+                    "--",
2046
+                    "sv",
2047
+                ],
2048
+                catch_exceptions=False,
2049
+            )
2050
+            assert result.error_exit(error="the user aborted the request"), (
2051
+                "expected known error message"
2052
+            )
2053
+            with cli_helpers.config_filename(subsystem="vault").open(
2054
+                encoding="UTF-8"
2055
+            ) as infile:
2056
+                config = json.load(infile)
2057
+            assert config == {
2058
+                "global": {"phrase": "abc"},
2059
+                "services": {"sv": {"notes": notes.strip()}},
2060
+            }
2061
+
2062
+    def test_223a_edit_empty_notes_abort(
2063
+        self,
2064
+    ) -> None:
2065
+        """Aborting editing notes works even if no notes are stored yet.
2066
+
2067
+        Aborting is only supported with the modern editor interface.
2068
+
2069
+        """
2070
+        runner = machinery.CliRunner(mix_stderr=False)
2071
+        # TODO(the-13th-letter): Rewrite using parenthesized
2072
+        # with-statements.
2073
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2074
+        with contextlib.ExitStack() as stack:
2075
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2076
+            stack.enter_context(
2077
+                pytest_machinery.isolated_vault_config(
2078
+                    monkeypatch=monkeypatch,
2079
+                    runner=runner,
2080
+                    vault_config={
2081
+                        "global": {"phrase": "abc"},
2082
+                        "services": {},
2083
+                    },
2084
+                )
2085
+            )
2086
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
2087
+            result = runner.invoke(
2088
+                cli.derivepassphrase_vault,
2089
+                [
2090
+                    "--config",
2091
+                    "--notes",
2092
+                    "--modern-editor-interface",
2093
+                    "--",
2094
+                    "sv",
2095
+                ],
2096
+                catch_exceptions=False,
2097
+            )
2098
+            assert result.error_exit(error="the user aborted the request"), (
2099
+                "expected known error message"
2100
+            )
2101
+            with cli_helpers.config_filename(subsystem="vault").open(
2102
+                encoding="UTF-8"
2103
+            ) as infile:
2104
+                config = json.load(infile)
2105
+            assert config == {
2106
+                "global": {"phrase": "abc"},
2107
+                "services": {},
2108
+            }
2109
+
2110
+    @Parametrize.MODERN_EDITOR_INTERFACE
2111
+    @hypothesis.settings(
2112
+        suppress_health_check=[
2113
+            *hypothesis.settings().suppress_health_check,
2114
+            hypothesis.HealthCheck.function_scoped_fixture,
2115
+        ],
2116
+    )
2117
+    @hypothesis.given(
2118
+        notes=strategies.text(
2119
+            strategies.characters(
2120
+                min_codepoint=32, max_codepoint=126, include_characters="\n"
2121
+            ),
2122
+            max_size=512,
2123
+        ),
2124
+    )
2125
+    def test_223b_edit_notes_fail_config_option_missing(
2126
+        self,
2127
+        caplog: pytest.LogCaptureFixture,
2128
+        modern_editor_interface: bool,
2129
+        notes: str,
2130
+    ) -> None:
2131
+        """Editing notes fails (and warns) if `--config` is missing."""
2132
+        maybe_notes = {"notes": notes.strip()} if notes.strip() else {}
2133
+        vault_config = {
2134
+            "global": {"phrase": DUMMY_PASSPHRASE},
2135
+            "services": {
2136
+                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
2137
+            },
2138
+        }
2139
+        # Reset caplog between hypothesis runs.
2140
+        caplog.clear()
2141
+        runner = machinery.CliRunner(mix_stderr=False)
2142
+        # TODO(the-13th-letter): Rewrite using parenthesized
2143
+        # with-statements.
2144
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2145
+        with contextlib.ExitStack() as stack:
2146
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2147
+            stack.enter_context(
2148
+                pytest_machinery.isolated_vault_config(
2149
+                    monkeypatch=monkeypatch,
2150
+                    runner=runner,
2151
+                    vault_config=vault_config,
2152
+                )
2153
+            )
2154
+            EDIT_ATTEMPTED = "edit attempted!"  # noqa: N806
2155
+
2156
+            def raiser(*_args: Any, **_kwargs: Any) -> NoReturn:
2157
+                pytest.fail(EDIT_ATTEMPTED)
2158
+
2159
+            notes_backup_file = cli_helpers.config_filename(
2160
+                subsystem="notes backup"
2161
+            )
2162
+            notes_backup_file.write_text(
2163
+                "These backup notes are left over from the previous session.",
2164
+                encoding="UTF-8",
2165
+            )
2166
+            monkeypatch.setattr(click, "edit", raiser)
2167
+            result = runner.invoke(
2168
+                cli.derivepassphrase_vault,
2169
+                [
2170
+                    "--notes",
2171
+                    "--modern-editor-interface"
2172
+                    if modern_editor_interface
2173
+                    else "--vault-legacy-editor-interface",
2174
+                    "--",
2175
+                    DUMMY_SERVICE,
2176
+                ],
2177
+                catch_exceptions=False,
2178
+            )
2179
+            assert result.clean_exit(
2180
+                output=DUMMY_RESULT_PASSPHRASE.decode("ascii")
2181
+            ), "expected clean exit"
2182
+            assert result.stderr
2183
+            assert notes.strip() in result.stderr
2184
+            assert all(
2185
+                is_warning_line(line)
2186
+                for line in result.stderr.splitlines(True)
2187
+                if line.startswith(f"{cli.PROG_NAME}: ")
2188
+            )
2189
+            assert machinery.warning_emitted(
2190
+                "Specifying --notes without --config is ineffective.  "
2191
+                "No notes will be edited.",
2192
+                caplog.record_tuples,
2193
+            ), "expected known warning message in stderr"
2194
+            assert (
2195
+                modern_editor_interface
2196
+                or notes_backup_file.read_text(encoding="UTF-8")
2197
+                == "These backup notes are left over from the previous session."
2198
+            )
2199
+            with cli_helpers.config_filename(subsystem="vault").open(
2200
+                encoding="UTF-8"
2201
+            ) as infile:
2202
+                config = json.load(infile)
2203
+            assert config == vault_config
2204
+
2205
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
2206
+    def test_224_store_config_good(
2207
+        self,
2208
+        command_line: list[str],
2209
+        input: str,
2210
+        result_config: Any,
2211
+    ) -> None:
2212
+        """Storing valid settings via `--config` works.
2213
+
2214
+        The format also contains embedded newlines and indentation to make
2215
+        the config more readable.
2216
+
2217
+        """
2218
+        runner = machinery.CliRunner(mix_stderr=False)
2219
+        # TODO(the-13th-letter): Rewrite using parenthesized
2220
+        # with-statements.
2221
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2222
+        with contextlib.ExitStack() as stack:
2223
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2224
+            stack.enter_context(
2225
+                pytest_machinery.isolated_vault_config(
2226
+                    monkeypatch=monkeypatch,
2227
+                    runner=runner,
2228
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2229
+                )
2230
+            )
2231
+            monkeypatch.setattr(
2232
+                cli_helpers,
2233
+                "get_suitable_ssh_keys",
2234
+                callables.suitable_ssh_keys,
2235
+            )
2236
+            result = runner.invoke(
2237
+                cli.derivepassphrase_vault,
2238
+                ["--config", *command_line],
2239
+                catch_exceptions=False,
2240
+                input=input,
2241
+            )
2242
+            assert result.clean_exit(), "expected clean exit"
2243
+            config_txt = cli_helpers.config_filename(
2244
+                subsystem="vault"
2245
+            ).read_text(encoding="UTF-8")
2246
+            config = json.loads(config_txt)
2247
+            assert config == result_config, (
2248
+                "stored config does not match expectation"
2249
+            )
2250
+            assert_vault_config_is_indented_and_line_broken(config_txt)
2251
+
2252
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
2253
+    def test_225_store_config_fail(
2254
+        self,
2255
+        command_line: list[str],
2256
+        input: str,
2257
+        err_text: str,
2258
+    ) -> None:
2259
+        """Storing invalid settings via `--config` fails."""
2260
+        runner = machinery.CliRunner(mix_stderr=False)
2261
+        # TODO(the-13th-letter): Rewrite using parenthesized
2262
+        # with-statements.
2263
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2264
+        with contextlib.ExitStack() as stack:
2265
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2266
+            stack.enter_context(
2267
+                pytest_machinery.isolated_vault_config(
2268
+                    monkeypatch=monkeypatch,
2269
+                    runner=runner,
2270
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2271
+                )
2272
+            )
2273
+            monkeypatch.setattr(
2274
+                cli_helpers,
2275
+                "get_suitable_ssh_keys",
2276
+                callables.suitable_ssh_keys,
2277
+            )
2278
+            result = runner.invoke(
2279
+                cli.derivepassphrase_vault,
2280
+                ["--config", *command_line],
2281
+                catch_exceptions=False,
2282
+                input=input,
2283
+            )
2284
+        assert result.error_exit(error=err_text), (
2285
+            "expected error exit and known error message"
2286
+        )
2287
+
2288
+    def test_225a_store_config_fail_manual_no_ssh_key_selection(
2289
+        self,
2290
+        running_ssh_agent: data.RunningSSHAgentInfo,
2291
+    ) -> None:
2292
+        """Not selecting an SSH key during `--config --key` fails."""
2293
+        del running_ssh_agent
2294
+        runner = machinery.CliRunner(mix_stderr=False)
2295
+        # TODO(the-13th-letter): Rewrite using parenthesized
2296
+        # with-statements.
2297
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2298
+        with contextlib.ExitStack() as stack:
2299
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2300
+            stack.enter_context(
2301
+                pytest_machinery.isolated_vault_config(
2302
+                    monkeypatch=monkeypatch,
2303
+                    runner=runner,
2304
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2305
+                )
2306
+            )
2307
+
2308
+            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
2309
+                raise IndexError(cli_helpers.EMPTY_SELECTION)
2310
+
2311
+            monkeypatch.setattr(
2312
+                cli_helpers, "prompt_for_selection", prompt_for_selection
2313
+            )
2314
+            # Also patch the list of suitable SSH keys, lest we be at
2315
+            # the mercy of whatever SSH agent may be running.
2316
+            monkeypatch.setattr(
2317
+                cli_helpers,
2318
+                "get_suitable_ssh_keys",
2319
+                callables.suitable_ssh_keys,
2320
+            )
2321
+            result = runner.invoke(
2322
+                cli.derivepassphrase_vault,
2323
+                ["--key", "--config"],
2324
+                catch_exceptions=False,
2325
+            )
2326
+        assert result.error_exit(error="the user aborted the request"), (
2327
+            "expected error exit and known error message"
2328
+        )
2329
+
2330
+    def test_225b_store_config_fail_manual_no_ssh_agent(
2331
+        self,
2332
+        running_ssh_agent: data.RunningSSHAgentInfo,
2333
+    ) -> None:
2334
+        """Not running an SSH agent during `--config --key` fails."""
2335
+        del running_ssh_agent
2336
+        runner = machinery.CliRunner(mix_stderr=False)
2337
+        # TODO(the-13th-letter): Rewrite using parenthesized
2338
+        # with-statements.
2339
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2340
+        with contextlib.ExitStack() as stack:
2341
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2342
+            stack.enter_context(
2343
+                pytest_machinery.isolated_vault_config(
2344
+                    monkeypatch=monkeypatch,
2345
+                    runner=runner,
2346
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2347
+                )
2348
+            )
2349
+            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
2350
+            result = runner.invoke(
2351
+                cli.derivepassphrase_vault,
2352
+                ["--key", "--config"],
2353
+                catch_exceptions=False,
2354
+            )
2355
+        assert result.error_exit(error="Cannot find any running SSH agent"), (
2356
+            "expected error exit and known error message"
2357
+        )
2358
+
2359
+    def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
2360
+        self,
2361
+        running_ssh_agent: data.RunningSSHAgentInfo,
2362
+    ) -> None:
2363
+        """Not running a reachable SSH agent during `--config --key` fails."""
2364
+        running_ssh_agent.require_external_address()
2365
+        runner = machinery.CliRunner(mix_stderr=False)
2366
+        # TODO(the-13th-letter): Rewrite using parenthesized
2367
+        # with-statements.
2368
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2369
+        with contextlib.ExitStack() as stack:
2370
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2371
+            stack.enter_context(
2372
+                pytest_machinery.isolated_vault_config(
2373
+                    monkeypatch=monkeypatch,
2374
+                    runner=runner,
2375
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2376
+                )
2377
+            )
2378
+            cwd = pathlib.Path.cwd().resolve()
2379
+            monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd))
2380
+            result = runner.invoke(
2381
+                cli.derivepassphrase_vault,
2382
+                ["--key", "--config"],
2383
+                catch_exceptions=False,
2384
+            )
2385
+        assert result.error_exit(error="Cannot connect to the SSH agent"), (
2386
+            "expected error exit and known error message"
2387
+        )
2388
+
2389
+    @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
2390
+    def test_225d_store_config_fail_manual_read_only_file(
2391
+        self,
2392
+        try_race_free_implementation: bool,
2393
+    ) -> None:
2394
+        """Using a read-only configuration file with `--config` fails."""
2395
+        runner = machinery.CliRunner(mix_stderr=False)
2396
+        # TODO(the-13th-letter): Rewrite using parenthesized
2397
+        # with-statements.
2398
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2399
+        with contextlib.ExitStack() as stack:
2400
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2401
+            stack.enter_context(
2402
+                pytest_machinery.isolated_vault_config(
2403
+                    monkeypatch=monkeypatch,
2404
+                    runner=runner,
2405
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2406
+                )
2407
+            )
2408
+            callables.make_file_readonly(
2409
+                cli_helpers.config_filename(subsystem="vault"),
2410
+                try_race_free_implementation=try_race_free_implementation,
2411
+            )
2412
+            result = runner.invoke(
2413
+                cli.derivepassphrase_vault,
2414
+                ["--config", "--length=15", "--", DUMMY_SERVICE],
2415
+                catch_exceptions=False,
2416
+            )
2417
+        assert result.error_exit(error="Cannot store vault settings:"), (
2418
+            "expected error exit and known error message"
2419
+        )
2420
+
2421
+    def test_225e_store_config_fail_manual_custom_error(
2422
+        self,
2423
+    ) -> None:
2424
+        """OS-erroring with `--config` fails."""
2425
+        runner = machinery.CliRunner(mix_stderr=False)
2426
+        # TODO(the-13th-letter): Rewrite using parenthesized
2427
+        # with-statements.
2428
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2429
+        with contextlib.ExitStack() as stack:
2430
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2431
+            stack.enter_context(
2432
+                pytest_machinery.isolated_vault_config(
2433
+                    monkeypatch=monkeypatch,
2434
+                    runner=runner,
2435
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2436
+                )
2437
+            )
2438
+            custom_error = "custom error message"
2439
+
2440
+            def raiser(config: Any) -> None:
2441
+                del config
2442
+                raise RuntimeError(custom_error)
2443
+
2444
+            monkeypatch.setattr(cli_helpers, "save_config", raiser)
2445
+            result = runner.invoke(
2446
+                cli.derivepassphrase_vault,
2447
+                ["--config", "--length=15", "--", DUMMY_SERVICE],
2448
+                catch_exceptions=False,
2449
+            )
2450
+        assert result.error_exit(error=custom_error), (
2451
+            "expected error exit and known error message"
2452
+        )
2453
+
2454
+    def test_225f_store_config_fail_unset_and_set_same_settings(
2455
+        self,
2456
+    ) -> None:
2457
+        """Issuing conflicting settings to `--config` fails."""
2458
+        runner = machinery.CliRunner(mix_stderr=False)
2459
+        # TODO(the-13th-letter): Rewrite using parenthesized
2460
+        # with-statements.
2461
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2462
+        with contextlib.ExitStack() as stack:
2463
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2464
+            stack.enter_context(
2465
+                pytest_machinery.isolated_vault_config(
2466
+                    monkeypatch=monkeypatch,
2467
+                    runner=runner,
2468
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2469
+                )
2470
+            )
2471
+            result = runner.invoke(
2472
+                cli.derivepassphrase_vault,
2473
+                [
2474
+                    "--config",
2475
+                    "--unset=length",
2476
+                    "--length=15",
2477
+                    "--",
2478
+                    DUMMY_SERVICE,
2479
+                ],
2480
+                catch_exceptions=False,
2481
+            )
2482
+        assert result.error_exit(
2483
+            error="Attempted to unset and set --length at the same time."
2484
+        ), "expected error exit and known error message"
2485
+
2486
+    def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded(
2487
+        self,
2488
+        running_ssh_agent: data.RunningSSHAgentInfo,
2489
+    ) -> None:
2490
+        """Not holding any SSH keys during `--config --key` fails."""
2491
+        del running_ssh_agent
2492
+        runner = machinery.CliRunner(mix_stderr=False)
2493
+        # TODO(the-13th-letter): Rewrite using parenthesized
2494
+        # with-statements.
2495
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2496
+        with contextlib.ExitStack() as stack:
2497
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2498
+            stack.enter_context(
2499
+                pytest_machinery.isolated_vault_config(
2500
+                    monkeypatch=monkeypatch,
2501
+                    runner=runner,
2502
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2503
+                )
2504
+            )
2505
+
2506
+            def func(
2507
+                *_args: Any,
2508
+                **_kwargs: Any,
2509
+            ) -> list[_types.SSHKeyCommentPair]:
2510
+                return []
2511
+
2512
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2513
+            result = runner.invoke(
2514
+                cli.derivepassphrase_vault,
2515
+                ["--key", "--config"],
2516
+                catch_exceptions=False,
2517
+            )
2518
+        assert result.error_exit(error="no keys suitable"), (
2519
+            "expected error exit and known error message"
2520
+        )
2521
+
2522
+    def test_225h_store_config_fail_manual_ssh_agent_runtime_error(
2523
+        self,
2524
+        running_ssh_agent: data.RunningSSHAgentInfo,
2525
+    ) -> None:
2526
+        """The SSH agent erroring during `--config --key` fails."""
2527
+        del running_ssh_agent
2528
+        runner = machinery.CliRunner(mix_stderr=False)
2529
+        # TODO(the-13th-letter): Rewrite using parenthesized
2530
+        # with-statements.
2531
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2532
+        with contextlib.ExitStack() as stack:
2533
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2534
+            stack.enter_context(
2535
+                pytest_machinery.isolated_vault_config(
2536
+                    monkeypatch=monkeypatch,
2537
+                    runner=runner,
2538
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2539
+                )
2540
+            )
2541
+
2542
+            def raiser(*_args: Any, **_kwargs: Any) -> None:
2543
+                raise ssh_agent.TrailingDataError()
2544
+
2545
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
2546
+            result = runner.invoke(
2547
+                cli.derivepassphrase_vault,
2548
+                ["--key", "--config"],
2549
+                catch_exceptions=False,
2550
+            )
2551
+        assert result.error_exit(
2552
+            error="violates the communication protocol."
2553
+        ), "expected error exit and known error message"
2554
+
2555
+    def test_225i_store_config_fail_manual_ssh_agent_refuses(
2556
+        self,
2557
+        running_ssh_agent: data.RunningSSHAgentInfo,
2558
+    ) -> None:
2559
+        """The SSH agent refusing during `--config --key` fails."""
2560
+        del running_ssh_agent
2561
+        runner = machinery.CliRunner(mix_stderr=False)
2562
+        # TODO(the-13th-letter): Rewrite using parenthesized
2563
+        # with-statements.
2564
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2565
+        with contextlib.ExitStack() as stack:
2566
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2567
+            stack.enter_context(
2568
+                pytest_machinery.isolated_vault_config(
2569
+                    monkeypatch=monkeypatch,
2570
+                    runner=runner,
2571
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2572
+                )
2573
+            )
2574
+
2575
+            def func(*_args: Any, **_kwargs: Any) -> NoReturn:
2576
+                raise ssh_agent.SSHAgentFailedError(
2577
+                    _types.SSH_AGENT.FAILURE, b""
2578
+                )
2579
+
2580
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
2581
+            result = runner.invoke(
2582
+                cli.derivepassphrase_vault,
2583
+                ["--key", "--config"],
2584
+                catch_exceptions=False,
2585
+            )
2586
+        assert result.error_exit(error="refused to"), (
2587
+            "expected error exit and known error message"
2588
+        )
2589
+
2590
+    def test_226_no_arguments(self) -> None:
2591
+        """Calling `derivepassphrase vault` without any arguments fails."""
2592
+        runner = machinery.CliRunner(mix_stderr=False)
2593
+        # TODO(the-13th-letter): Rewrite using parenthesized
2594
+        # with-statements.
2595
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2596
+        with contextlib.ExitStack() as stack:
2597
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2598
+            stack.enter_context(
2599
+                pytest_machinery.isolated_config(
2600
+                    monkeypatch=monkeypatch,
2601
+                    runner=runner,
2602
+                )
2603
+            )
2604
+            result = runner.invoke(
2605
+                cli.derivepassphrase_vault, [], catch_exceptions=False
2606
+            )
2607
+        assert result.error_exit(
2608
+            error="Deriving a passphrase requires a SERVICE"
2609
+        ), "expected error exit and known error message"
2610
+
2611
+    def test_226a_no_passphrase_or_key(
2612
+        self,
2613
+    ) -> None:
2614
+        """Deriving a passphrase without a passphrase or key fails."""
2615
+        runner = machinery.CliRunner(mix_stderr=False)
2616
+        # TODO(the-13th-letter): Rewrite using parenthesized
2617
+        # with-statements.
2618
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2619
+        with contextlib.ExitStack() as stack:
2620
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2621
+            stack.enter_context(
2622
+                pytest_machinery.isolated_config(
2623
+                    monkeypatch=monkeypatch,
2624
+                    runner=runner,
2625
+                )
2626
+            )
2627
+            result = runner.invoke(
2628
+                cli.derivepassphrase_vault,
2629
+                ["--", DUMMY_SERVICE],
2630
+                catch_exceptions=False,
2631
+            )
2632
+        assert result.error_exit(error="No passphrase or key was given"), (
2633
+            "expected error exit and known error message"
2634
+        )
2635
+
2636
+    def test_230_config_directory_nonexistant(
2637
+        self,
2638
+    ) -> None:
2639
+        """Running without an existing config directory works.
2640
+
2641
+        This is a regression test; see [issue\u00a0#6][] for context.
2642
+
2643
+        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
2644
+
2645
+        """
2646
+        runner = machinery.CliRunner(mix_stderr=False)
2647
+        # TODO(the-13th-letter): Rewrite using parenthesized
2648
+        # with-statements.
2649
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2650
+        with contextlib.ExitStack() as stack:
2651
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2652
+            stack.enter_context(
2653
+                pytest_machinery.isolated_config(
2654
+                    monkeypatch=monkeypatch,
2655
+                    runner=runner,
2656
+                )
2657
+            )
2658
+            with contextlib.suppress(FileNotFoundError):
2659
+                shutil.rmtree(cli_helpers.config_filename(subsystem=None))
2660
+            result = runner.invoke(
2661
+                cli.derivepassphrase_vault,
2662
+                ["--config", "-p"],
2663
+                catch_exceptions=False,
2664
+                input="abc\n",
2665
+            )
2666
+            assert result.clean_exit(), "expected clean exit"
2667
+            assert result.stderr == "Passphrase:", (
2668
+                "program unexpectedly failed?!"
2669
+            )
2670
+            with cli_helpers.config_filename(subsystem="vault").open(
2671
+                encoding="UTF-8"
2672
+            ) as infile:
2673
+                config_readback = json.load(infile)
2674
+            assert config_readback == {
2675
+                "global": {"phrase": "abc"},
2676
+                "services": {},
2677
+            }, "config mismatch"
2678
+
2679
+    def test_230a_config_directory_not_a_file(
2680
+        self,
2681
+    ) -> None:
2682
+        """Erroring without an existing config directory errors normally.
2683
+
2684
+        That is, the missing configuration directory does not cause any
2685
+        errors by itself.
2686
+
2687
+        This is a regression test; see [issue\u00a0#6][] for context.
2688
+
2689
+        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
2690
+
2691
+        """
2692
+        runner = machinery.CliRunner(mix_stderr=False)
2693
+        # TODO(the-13th-letter): Rewrite using parenthesized
2694
+        # with-statements.
2695
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2696
+        with contextlib.ExitStack() as stack:
2697
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2698
+            stack.enter_context(
2699
+                pytest_machinery.isolated_config(
2700
+                    monkeypatch=monkeypatch,
2701
+                    runner=runner,
2702
+                )
2703
+            )
2704
+            save_config_ = cli_helpers.save_config
2705
+
2706
+            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
2707
+                config_dir = cli_helpers.config_filename(subsystem=None)
2708
+                with contextlib.suppress(FileNotFoundError):
2709
+                    shutil.rmtree(config_dir)
2710
+                config_dir.write_text("Obstruction!!\n")
2711
+                monkeypatch.setattr(cli_helpers, "save_config", save_config_)
2712
+                return save_config_(*args, **kwargs)
2713
+
2714
+            monkeypatch.setattr(
2715
+                cli_helpers, "save_config", obstruct_config_saving
2716
+            )
2717
+            result = runner.invoke(
2718
+                cli.derivepassphrase_vault,
2719
+                ["--config", "-p"],
2720
+                catch_exceptions=False,
2721
+                input="abc\n",
2722
+            )
2723
+            assert result.error_exit(error="Cannot store vault settings:"), (
2724
+                "expected error exit and known error message"
2725
+            )
2726
+
2727
+    def test_230b_store_config_custom_error(
2728
+        self,
2729
+    ) -> None:
2730
+        """Storing the configuration reacts even to weird errors."""
2731
+        runner = machinery.CliRunner(mix_stderr=False)
2732
+        # TODO(the-13th-letter): Rewrite using parenthesized
2733
+        # with-statements.
2734
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2735
+        with contextlib.ExitStack() as stack:
2736
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2737
+            stack.enter_context(
2738
+                pytest_machinery.isolated_config(
2739
+                    monkeypatch=monkeypatch,
2740
+                    runner=runner,
2741
+                )
2742
+            )
2743
+            custom_error = "custom error message"
2744
+
2745
+            def raiser(config: Any) -> None:
2746
+                del config
2747
+                raise RuntimeError(custom_error)
2748
+
2749
+            monkeypatch.setattr(cli_helpers, "save_config", raiser)
2750
+            result = runner.invoke(
2751
+                cli.derivepassphrase_vault,
2752
+                ["--config", "-p"],
2753
+                catch_exceptions=False,
2754
+                input="abc\n",
2755
+            )
2756
+            assert result.error_exit(error=custom_error), (
2757
+                "expected error exit and known error message"
2758
+            )
2759
+
2760
+    @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
2761
+    def test_300_unicode_normalization_form_warning(
2762
+        self,
2763
+        caplog: pytest.LogCaptureFixture,
2764
+        main_config: str,
2765
+        command_line: list[str],
2766
+        input: str | None,
2767
+        warning_message: str,
2768
+    ) -> None:
2769
+        """Using unnormalized Unicode passphrases warns."""
2770
+        runner = machinery.CliRunner(mix_stderr=False)
2771
+        # TODO(the-13th-letter): Rewrite using parenthesized
2772
+        # with-statements.
2773
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2774
+        with contextlib.ExitStack() as stack:
2775
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2776
+            stack.enter_context(
2777
+                pytest_machinery.isolated_vault_config(
2778
+                    monkeypatch=monkeypatch,
2779
+                    runner=runner,
2780
+                    vault_config={
2781
+                        "services": {
2782
+                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2783
+                        }
2784
+                    },
2785
+                    main_config_str=main_config,
2786
+                )
2787
+            )
2788
+            result = runner.invoke(
2789
+                cli.derivepassphrase_vault,
2790
+                ["--debug", *command_line],
2791
+                catch_exceptions=False,
2792
+                input=input,
2793
+            )
2794
+        assert result.clean_exit(), "expected clean exit"
2795
+        assert machinery.warning_emitted(
2796
+            warning_message, caplog.record_tuples
2797
+        ), "expected known warning message in stderr"
2798
+
2799
+    @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
2800
+    def test_301_unicode_normalization_form_error(
2801
+        self,
2802
+        main_config: str,
2803
+        command_line: list[str],
2804
+        input: str | None,
2805
+        error_message: str,
2806
+    ) -> None:
2807
+        """Using unknown Unicode normalization forms fails."""
2808
+        runner = machinery.CliRunner(mix_stderr=False)
2809
+        # TODO(the-13th-letter): Rewrite using parenthesized
2810
+        # with-statements.
2811
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2812
+        with contextlib.ExitStack() as stack:
2813
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2814
+            stack.enter_context(
2815
+                pytest_machinery.isolated_vault_config(
2816
+                    monkeypatch=monkeypatch,
2817
+                    runner=runner,
2818
+                    vault_config={
2819
+                        "services": {
2820
+                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2821
+                        }
2822
+                    },
2823
+                    main_config_str=main_config,
2824
+                )
2825
+            )
2826
+            result = runner.invoke(
2827
+                cli.derivepassphrase_vault,
2828
+                command_line,
2829
+                catch_exceptions=False,
2830
+                input=input,
2831
+            )
2832
+        assert result.error_exit(
2833
+            error="The user configuration file is invalid."
2834
+        ), "expected error exit and known error message"
2835
+        assert result.error_exit(error=error_message), (
2836
+            "expected error exit and known error message"
2837
+        )
2838
+
2839
+    @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES
2840
+    def test_301a_unicode_normalization_form_error_from_stored_config(
2841
+        self,
2842
+        command_line: list[str],
2843
+    ) -> None:
2844
+        """Using unknown Unicode normalization forms in the config fails."""
2845
+        runner = machinery.CliRunner(mix_stderr=False)
2846
+        # TODO(the-13th-letter): Rewrite using parenthesized
2847
+        # with-statements.
2848
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2849
+        with contextlib.ExitStack() as stack:
2850
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2851
+            stack.enter_context(
2852
+                pytest_machinery.isolated_vault_config(
2853
+                    monkeypatch=monkeypatch,
2854
+                    runner=runner,
2855
+                    vault_config={
2856
+                        "services": {
2857
+                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2858
+                        }
2859
+                    },
2860
+                    main_config_str=(
2861
+                        "[vault]\ndefault-unicode-normalization-form = 'XXX'\n"
2862
+                    ),
2863
+                )
2864
+            )
2865
+            result = runner.invoke(
2866
+                cli.derivepassphrase_vault,
2867
+                command_line,
2868
+                input=DUMMY_PASSPHRASE,
2869
+                catch_exceptions=False,
2870
+            )
2871
+            assert result.error_exit(
2872
+                error="The user configuration file is invalid."
2873
+            ), "expected error exit and known error message"
2874
+            assert result.error_exit(
2875
+                error=(
2876
+                    "Invalid value 'XXX' for config key "
2877
+                    "vault.default-unicode-normalization-form"
2878
+                ),
2879
+            ), "expected error exit and known error message"
2880
+
2881
+    def test_310_bad_user_config_file(
2882
+        self,
2883
+    ) -> None:
2884
+        """Loading a user configuration file in an invalid format fails."""
2885
+        runner = machinery.CliRunner(mix_stderr=False)
2886
+        # TODO(the-13th-letter): Rewrite using parenthesized
2887
+        # with-statements.
2888
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2889
+        with contextlib.ExitStack() as stack:
2890
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2891
+            stack.enter_context(
2892
+                pytest_machinery.isolated_vault_config(
2893
+                    monkeypatch=monkeypatch,
2894
+                    runner=runner,
2895
+                    vault_config={"services": {}},
2896
+                    main_config_str="This file is not valid TOML.\n",
2897
+                )
2898
+            )
2899
+            result = runner.invoke(
2900
+                cli.derivepassphrase_vault,
2901
+                ["--phrase", "--", DUMMY_SERVICE],
2902
+                input=DUMMY_PASSPHRASE,
2903
+                catch_exceptions=False,
2904
+            )
2905
+            assert result.error_exit(error="Cannot load user config:"), (
2906
+                "expected error exit and known error message"
2907
+            )
2908
+
2909
+    def test_311_bad_user_config_is_a_directory(
2910
+        self,
2911
+    ) -> None:
2912
+        """Loading a user configuration file in an invalid format fails."""
2913
+        runner = machinery.CliRunner(mix_stderr=False)
2914
+        # TODO(the-13th-letter): Rewrite using parenthesized
2915
+        # with-statements.
2916
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2917
+        with contextlib.ExitStack() as stack:
2918
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2919
+            stack.enter_context(
2920
+                pytest_machinery.isolated_vault_config(
2921
+                    monkeypatch=monkeypatch,
2922
+                    runner=runner,
2923
+                    vault_config={"services": {}},
2924
+                    main_config_str="",
2925
+                )
2926
+            )
2927
+            user_config = cli_helpers.config_filename(
2928
+                subsystem="user configuration"
2929
+            )
2930
+            user_config.unlink()
2931
+            user_config.mkdir(parents=True, exist_ok=True)
2932
+            result = runner.invoke(
2933
+                cli.derivepassphrase_vault,
2934
+                ["--phrase", "--", DUMMY_SERVICE],
2935
+                input=DUMMY_PASSPHRASE,
2936
+                catch_exceptions=False,
2937
+            )
2938
+            assert result.error_exit(error="Cannot load user config:"), (
2939
+                "expected error exit and known error message"
2940
+            )
2941
+
2942
+    def test_400_missing_af_unix_support(
2943
+        self,
2944
+        caplog: pytest.LogCaptureFixture,
2945
+    ) -> None:
2946
+        """Querying the SSH agent without `AF_UNIX` support fails."""
2947
+        runner = machinery.CliRunner(mix_stderr=False)
2948
+        # TODO(the-13th-letter): Rewrite using parenthesized
2949
+        # with-statements.
2950
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2951
+        with contextlib.ExitStack() as stack:
2952
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2953
+            stack.enter_context(
2954
+                pytest_machinery.isolated_vault_config(
2955
+                    monkeypatch=monkeypatch,
2956
+                    runner=runner,
2957
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2958
+                )
2959
+            )
2960
+            monkeypatch.setenv(
2961
+                "SSH_AUTH_SOCK", "the value doesn't even matter"
2962
+            )
2963
+            monkeypatch.setattr(
2964
+                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"]
2965
+            )
2966
+            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
2967
+            result = runner.invoke(
2968
+                cli.derivepassphrase_vault,
2969
+                ["--key", "--config"],
2970
+                catch_exceptions=False,
2971
+            )
2972
+        assert result.error_exit(
2973
+            error="does not support communicating with it"
2974
+        ), "expected error exit and known error message"
2975
+        assert machinery.warning_emitted(
2976
+            "Cannot connect to an SSH agent via UNIX domain sockets",
2977
+            caplog.record_tuples,
2978
+        ), "expected known warning message in stderr"
... ...
@@ -22,19 +22,17 @@ from derivepassphrase import cli, vault
22 22
 from derivepassphrase._internals import (
23 23
     cli_helpers,
24 24
 )
25
-from tests import data, machinery, test_derivepassphrase_cli
25
+from tests import data, machinery
26 26
 from tests.data import callables
27 27
 from tests.machinery import pytest as pytest_machinery
28
-from tests.test_derivepassphrase_cli import test_utils
28
+from tests.test_derivepassphrase_cli import test_000_basic, test_utils
29 29
 
30 30
 DUMMY_SERVICE = data.DUMMY_SERVICE
31 31
 DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
32 32
 DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
33 33
 
34 34
 
35
-class Parametrize(
36
-    test_derivepassphrase_cli.Parametrize, test_utils.Parametrize
37
-):
35
+class Parametrize(test_000_basic.Parametrize, test_utils.Parametrize):
38 36
     """Common test parametrizations."""
39 37
 
40 38
     BAD_CONFIGS = pytest.mark.parametrize(
... ...
@@ -1,1686 +1,3 @@
1 1
 # SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2 2
 #
3 3
 # SPDX-License-Identifier: Zlib
4
-
5
-"""Test OpenSSH key loading and signing."""
6
-
7
-from __future__ import annotations
8
-
9
-import base64
10
-import contextlib
11
-import errno
12
-import importlib.metadata
13
-import io
14
-import os
15
-import pathlib
16
-import re
17
-import socket
18
-import sys
19
-import types
20
-from typing import TYPE_CHECKING
21
-
22
-import click
23
-import click.testing
24
-import hypothesis
25
-import pytest
26
-from hypothesis import strategies
27
-
28
-from derivepassphrase import _types, ssh_agent, vault
29
-from derivepassphrase._internals import cli_helpers
30
-from derivepassphrase.ssh_agent import socketprovider
31
-from tests import data, machinery
32
-from tests.data import callables
33
-from tests.machinery import pytest as pytest_machinery
34
-
35
-if TYPE_CHECKING:
36
-    from collections.abc import Iterable
37
-
38
-    from typing_extensions import Any, Buffer, Literal
39
-
40
-if sys.version_info < (3, 11):
41
-    from exceptiongroup import ExceptionGroup
42
-
43
-
44
-class Parametrize(types.SimpleNamespace):
45
-    BAD_ENTRY_POINTS = pytest.mark.parametrize(
46
-        "additional_entry_points",
47
-        [
48
-            pytest.param(
49
-                [
50
-                    importlib.metadata.EntryPoint(
51
-                        name=data.faulty_entry_callable.key,
52
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
53
-                        value="tests.data: faulty_entry_callable",
54
-                    ),
55
-                ],
56
-                id="not-callable",
57
-            ),
58
-            pytest.param(
59
-                [
60
-                    importlib.metadata.EntryPoint(
61
-                        name=data.faulty_entry_name_exists.key,
62
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
63
-                        value="tests.data: faulty_entry_name_exists",
64
-                    ),
65
-                ],
66
-                id="name-already-exists",
67
-            ),
68
-            pytest.param(
69
-                [
70
-                    importlib.metadata.EntryPoint(
71
-                        name=data.faulty_entry_alias_exists.key,
72
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
73
-                        value="tests.data: faulty_entry_alias_exists",
74
-                    ),
75
-                ],
76
-                id="alias-already-exists",
77
-            ),
78
-        ],
79
-    )
80
-    GOOD_ENTRY_POINTS = pytest.mark.parametrize(
81
-        "additional_entry_points",
82
-        [
83
-            pytest.param(
84
-                [
85
-                    importlib.metadata.EntryPoint(
86
-                        name=data.posix_entry.key,
87
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
88
-                        value="tests.data: posix_entry",
89
-                    ),
90
-                    importlib.metadata.EntryPoint(
91
-                        name=data.the_annoying_os_entry.key,
92
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
93
-                        value="tests.data: the_annoying_os_entry",
94
-                    ),
95
-                ],
96
-                id="existing-entries",
97
-            ),
98
-            pytest.param(
99
-                [
100
-                    importlib.metadata.EntryPoint(
101
-                        name=callables.provider_entry1.key,
102
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
103
-                        value="tests.data.callables: provider_entry1",
104
-                    ),
105
-                    importlib.metadata.EntryPoint(
106
-                        name=callables.provider_entry2.key,
107
-                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
108
-                        value="tests.data.callables: provider_entry2",
109
-                    ),
110
-                ],
111
-                id="new-entries",
112
-            ),
113
-        ],
114
-    )
115
-    STUBBED_AGENT_ADDRESSES = pytest.mark.parametrize(
116
-        ["address", "exception", "match"],
117
-        [
118
-            pytest.param(None, KeyError, "SSH_AUTH_SOCK", id="unset"),
119
-            pytest.param("stub-ssh-agent:", None, "", id="standard"),
120
-            pytest.param(
121
-                str(pathlib.Path("~").expanduser()),
122
-                FileNotFoundError,
123
-                os.strerror(errno.ENOENT),
124
-                id="invalid-url",
125
-            ),
126
-            pytest.param(
127
-                "stub-ssh-agent:EPROTONOSUPPORT",
128
-                OSError,
129
-                os.strerror(errno.EPROTONOSUPPORT),
130
-                id="protocol-not-supported",
131
-            ),
132
-            pytest.param(
133
-                "stub-ssh-agent:ABCDEFGHIJKLMNOPQRSTUVWXYZ",
134
-                OSError,
135
-                os.strerror(errno.EINVAL),
136
-                id="invalid-error-code",
137
-            ),
138
-        ],
139
-    )
140
-    EXISTING_REGISTRY_ENTRIES = pytest.mark.parametrize(
141
-        "existing", ["posix", "the_annoying_os"]
142
-    )
143
-    SSH_STRING_EXCEPTIONS = pytest.mark.parametrize(
144
-        ["input", "exc_type", "exc_pattern"],
145
-        [
146
-            pytest.param(
147
-                "some string", TypeError, "invalid payload type", id="str"
148
-            ),
149
-        ],
150
-    )
151
-    UINT32_EXCEPTIONS = pytest.mark.parametrize(
152
-        ["input", "exc_type", "exc_pattern"],
153
-        [
154
-            pytest.param(
155
-                10000000000000000,
156
-                OverflowError,
157
-                "int too big to convert",
158
-                id="10000000000000000",
159
-            ),
160
-            pytest.param(
161
-                -1,
162
-                OverflowError,
163
-                "can't convert negative int to unsigned",
164
-                id="-1",
165
-            ),
166
-        ],
167
-    )
168
-    SSH_UNSTRING_EXCEPTIONS = pytest.mark.parametrize(
169
-        ["input", "exc_type", "exc_pattern", "has_trailer", "parts"],
170
-        [
171
-            pytest.param(
172
-                b"ssh",
173
-                ValueError,
174
-                "malformed SSH byte string",
175
-                False,
176
-                None,
177
-                id="unencoded",
178
-            ),
179
-            pytest.param(
180
-                b"\x00\x00\x00\x08ssh-rsa",
181
-                ValueError,
182
-                "malformed SSH byte string",
183
-                False,
184
-                None,
185
-                id="truncated",
186
-            ),
187
-            pytest.param(
188
-                b"\x00\x00\x00\x04XXX trailing text",
189
-                ValueError,
190
-                "malformed SSH byte string",
191
-                True,
192
-                (b"XXX ", b"trailing text"),
193
-                id="trailing-data",
194
-            ),
195
-        ],
196
-    )
197
-    SSH_STRING_INPUT = pytest.mark.parametrize(
198
-        ["input", "expected"],
199
-        [
200
-            pytest.param(
201
-                b"ssh-rsa",
202
-                b"\x00\x00\x00\x07ssh-rsa",
203
-                id="ssh-rsa",
204
-            ),
205
-            pytest.param(
206
-                b"ssh-ed25519",
207
-                b"\x00\x00\x00\x0bssh-ed25519",
208
-                id="ssh-ed25519",
209
-            ),
210
-            pytest.param(
211
-                ssh_agent.SSHAgentClient.string(b"ssh-ed25519"),
212
-                b"\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519",
213
-                id="string(ssh-ed25519)",
214
-            ),
215
-        ],
216
-    )
217
-    SSH_UNSTRING_INPUT = pytest.mark.parametrize(
218
-        ["input", "expected"],
219
-        [
220
-            pytest.param(
221
-                b"\x00\x00\x00\x07ssh-rsa",
222
-                b"ssh-rsa",
223
-                id="ssh-rsa",
224
-            ),
225
-            pytest.param(
226
-                ssh_agent.SSHAgentClient.string(b"ssh-ed25519"),
227
-                b"ssh-ed25519",
228
-                id="ssh-ed25519",
229
-            ),
230
-        ],
231
-    )
232
-    UINT32_INPUT = pytest.mark.parametrize(
233
-        ["input", "expected"],
234
-        [
235
-            pytest.param(16777216, b"\x01\x00\x00\x00", id="16777216"),
236
-        ],
237
-    )
238
-    SIGN_ERROR_RESPONSES = pytest.mark.parametrize(
239
-        [
240
-            "key",
241
-            "check",
242
-            "response_code",
243
-            "response",
244
-            "exc_type",
245
-            "exc_pattern",
246
-        ],
247
-        [
248
-            pytest.param(
249
-                b"invalid-key",
250
-                True,
251
-                _types.SSH_AGENT.FAILURE,
252
-                b"",
253
-                KeyError,
254
-                "target SSH key not loaded into agent",
255
-                id="key-not-loaded",
256
-            ),
257
-            pytest.param(
258
-                data.SUPPORTED_KEYS["ed25519"].public_key_data,
259
-                True,
260
-                _types.SSH_AGENT.FAILURE,
261
-                b"",
262
-                ssh_agent.SSHAgentFailedError,
263
-                "failed to complete the request",
264
-                id="failed-to-complete",
265
-            ),
266
-        ],
267
-    )
268
-    SSH_KEY_SELECTION = pytest.mark.parametrize(
269
-        ["key", "single"],
270
-        [
271
-            (value.public_key_data, False)
272
-            for value in data.SUPPORTED_KEYS.values()
273
-        ]
274
-        + [(callables.list_keys_singleton()[0].key, True)],
275
-        ids=[*data.SUPPORTED_KEYS.keys(), "singleton"],
276
-    )
277
-    SH_EXPORT_LINES = pytest.mark.parametrize(
278
-        ["line", "env_name", "value"],
279
-        [
280
-            pytest.param(
281
-                "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK;",
282
-                "SSH_AUTH_SOCK",
283
-                "/tmp/pageant.user/pageant.27170",
284
-                id="value-export-semicolon-pageant",
285
-            ),
286
-            pytest.param(
287
-                "SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270; export SSH_AUTH_SOCK;",
288
-                "SSH_AUTH_SOCK",
289
-                "/tmp/ssh-3CSTC1W5M22A/agent.27270",
290
-                id="value-export-semicolon-openssh",
291
-            ),
292
-            pytest.param(
293
-                "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK",
294
-                "SSH_AUTH_SOCK",
295
-                "/tmp/pageant.user/pageant.27170",
296
-                id="value-export-pageant",
297
-            ),
298
-            pytest.param(
299
-                "export SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270;",
300
-                "SSH_AUTH_SOCK",
301
-                "/tmp/ssh-3CSTC1W5M22A/agent.27270",
302
-                id="export-value-semicolon-openssh",
303
-            ),
304
-            pytest.param(
305
-                "export SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170",
306
-                "SSH_AUTH_SOCK",
307
-                "/tmp/pageant.user/pageant.27170",
308
-                id="export-value-pageant",
309
-            ),
310
-            pytest.param(
311
-                "SSH_AGENT_PID=27170; export SSH_AGENT_PID;",
312
-                "SSH_AGENT_PID",
313
-                "27170",
314
-                id="pid-export-semicolon",
315
-            ),
316
-            pytest.param(
317
-                "SSH_AGENT_PID=27170; export SSH_AGENT_PID",
318
-                "SSH_AGENT_PID",
319
-                "27170",
320
-                id="pid-export",
321
-            ),
322
-            pytest.param(
323
-                "export SSH_AGENT_PID=27170;",
324
-                "SSH_AGENT_PID",
325
-                "27170",
326
-                id="export-pid-semicolon",
327
-            ),
328
-            pytest.param(
329
-                "export SSH_AGENT_PID=27170",
330
-                "SSH_AGENT_PID",
331
-                "27170",
332
-                id="export-pid",
333
-            ),
334
-            pytest.param(
335
-                "export VARIABLE=value; export OTHER_VARIABLE=other_value;",
336
-                "VARIABLE",
337
-                None,
338
-                id="export-too-much",
339
-            ),
340
-            pytest.param(
341
-                "VARIABLE=value",
342
-                "VARIABLE",
343
-                None,
344
-                id="no-export",
345
-            ),
346
-        ],
347
-    )
348
-    INVALID_SSH_AGENT_MESSAGES = pytest.mark.parametrize(
349
-        "message",
350
-        [
351
-            pytest.param(b"\x00\x00\x00\x00", id="empty-message"),
352
-            pytest.param(b"\x00\x00\x00\x0f\x0d", id="truncated-message"),
353
-            pytest.param(
354
-                b"\x00\x00\x00\x06\x1b\x00\x00\x00\x01\xff",
355
-                id="invalid-extension-name",
356
-            ),
357
-            pytest.param(
358
-                b"\x00\x00\x00\x11\x0d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
359
-                id="sign-with-trailing-data",
360
-            ),
361
-        ],
362
-    )
363
-    UNSUPPORTED_SSH_AGENT_MESSAGES = pytest.mark.parametrize(
364
-        "message",
365
-        [
366
-            pytest.param(
367
-                ssh_agent.SSHAgentClient.string(
368
-                    b"".join([
369
-                        b"\x0d",
370
-                        ssh_agent.SSHAgentClient.string(
371
-                            data.ALL_KEYS["rsa"].public_key_data
372
-                        ),
373
-                        ssh_agent.SSHAgentClient.string(vault.Vault.UUID),
374
-                        b"\x00\x00\x00\x02",
375
-                    ])
376
-                ),
377
-                id="sign-with-flags",
378
-            ),
379
-            pytest.param(
380
-                ssh_agent.SSHAgentClient.string(
381
-                    b"".join([
382
-                        b"\x0d",
383
-                        ssh_agent.SSHAgentClient.string(
384
-                            data.ALL_KEYS["ed25519"].public_key_data
385
-                        ),
386
-                        b"\x00\x00\x00\x08\x00\x01\x02\x03\x04\x05\x06\x07",
387
-                        b"\x00\x00\x00\x00",
388
-                    ])
389
-                ),
390
-                id="sign-with-nonstandard-passphrase",
391
-            ),
392
-            pytest.param(
393
-                ssh_agent.SSHAgentClient.string(
394
-                    b"".join([
395
-                        b"\x0d",
396
-                        ssh_agent.SSHAgentClient.string(
397
-                            data.ALL_KEYS["dsa1024"].public_key_data
398
-                        ),
399
-                        ssh_agent.SSHAgentClient.string(vault.Vault.UUID),
400
-                        b"\x00\x00\x00\x00",
401
-                    ])
402
-                ),
403
-                id="sign-key-no-expected-signature",
404
-            ),
405
-            pytest.param(
406
-                ssh_agent.SSHAgentClient.string(
407
-                    b"".join([
408
-                        b"\x0d",
409
-                        b"\x00\x00\x00\x00",
410
-                        ssh_agent.SSHAgentClient.string(vault.Vault.UUID),
411
-                        b"\x00\x00\x00\x00",
412
-                    ])
413
-                ),
414
-                id="sign-key-unregistered-test-key",
415
-            ),
416
-        ],
417
-    )
418
-    PUBLIC_KEY_DATA = pytest.mark.parametrize(
419
-        "public_key_struct",
420
-        list(data.SUPPORTED_KEYS.values()),
421
-        ids=list(data.SUPPORTED_KEYS.keys()),
422
-    )
423
-    REQUEST_ERROR_RESPONSES = pytest.mark.parametrize(
424
-        ["request_code", "response_code", "exc_type", "exc_pattern"],
425
-        [
426
-            pytest.param(
427
-                _types.SSH_AGENTC.REQUEST_IDENTITIES,
428
-                _types.SSH_AGENT.SUCCESS,
429
-                ssh_agent.SSHAgentFailedError,
430
-                re.escape(
431
-                    f"[Code {_types.SSH_AGENT.IDENTITIES_ANSWER.value}]"
432
-                ),
433
-                id="REQUEST_IDENTITIES-expect-SUCCESS",
434
-            ),
435
-        ],
436
-    )
437
-    TRUNCATED_AGENT_RESPONSES = pytest.mark.parametrize(
438
-        "response",
439
-        [
440
-            b"\x00\x00",
441
-            b"\x00\x00\x00\x1f some bytes missing",
442
-        ],
443
-        ids=["in-header", "in-body"],
444
-    )
445
-    LIST_KEYS_ERROR_RESPONSES = pytest.mark.parametrize(
446
-        ["response_code", "response", "exc_type", "exc_pattern"],
447
-        [
448
-            pytest.param(
449
-                _types.SSH_AGENT.FAILURE,
450
-                b"",
451
-                ssh_agent.SSHAgentFailedError,
452
-                "failed to complete the request",
453
-                id="failed-to-complete",
454
-            ),
455
-            pytest.param(
456
-                _types.SSH_AGENT.IDENTITIES_ANSWER,
457
-                b"\x00\x00\x00\x01",
458
-                EOFError,
459
-                "truncated response",
460
-                id="truncated-response",
461
-            ),
462
-            pytest.param(
463
-                _types.SSH_AGENT.IDENTITIES_ANSWER,
464
-                b"\x00\x00\x00\x00abc",
465
-                ssh_agent.TrailingDataError,
466
-                "Overlong response",
467
-                id="overlong-response",
468
-            ),
469
-        ],
470
-    )
471
-    QUERY_EXTENSIONS_MALFORMED_RESPONSES = pytest.mark.parametrize(
472
-        "response_data",
473
-        [
474
-            pytest.param(b"\xde\xad\xbe\xef", id="truncated"),
475
-            pytest.param(
476
-                b"\x00\x00\x00\x0fwrong extension", id="wrong-extension"
477
-            ),
478
-            pytest.param(
479
-                b"\x00\x00\x00\x05query\xde\xad\xbe\xef", id="with-trailer"
480
-            ),
481
-            pytest.param(
482
-                b"\x00\x00\x00\x05query\x00\x00\x00\x04ext1\x00\x00",
483
-                id="with-extra-fields",
484
-            ),
485
-        ],
486
-    )
487
-    SUPPORTED_SSH_TEST_KEYS = pytest.mark.parametrize(
488
-        ["ssh_test_key_type", "ssh_test_key"],
489
-        list(data.SUPPORTED_KEYS.items()),
490
-        ids=data.SUPPORTED_KEYS.keys(),
491
-    )
492
-    UNSUITABLE_SSH_TEST_KEYS = pytest.mark.parametrize(
493
-        ["ssh_test_key_type", "ssh_test_key"],
494
-        list(data.UNSUITABLE_KEYS.items()),
495
-        ids=data.UNSUITABLE_KEYS.keys(),
496
-    )
497
-    RESOLVE_CHAINS = pytest.mark.parametrize(
498
-        ["terminal", "chain"],
499
-        [
500
-            pytest.param("callable", ["a"], id="callable-1"),
501
-            pytest.param("callable", ["a", "b", "c", "d"], id="callable-4"),
502
-            pytest.param("alias", ["e"], id="alias-5"),
503
-            pytest.param("alias", ["e", "f", "g", "h", "i"], id="alias-5"),
504
-            pytest.param("unimplemented", ["j"], id="unimplemented-1"),
505
-            pytest.param("unimplemented", ["j", "k"], id="unimplemented-2"),
506
-        ],
507
-    )
508
-
509
-
510
-class TestTestingMachineryStubbedSSHAgentSocket:
511
-    """Test the stubbed SSH agent socket for the `ssh_agent` module tests."""
512
-
513
-    def test_100a_query_extensions_base(self) -> None:
514
-        """The base agent implements no extensions."""
515
-        with contextlib.ExitStack() as stack:
516
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
517
-            monkeypatch.setenv(
518
-                "SSH_AUTH_SOCK",
519
-                machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
520
-            )
521
-            agent = stack.enter_context(
522
-                machinery.StubbedSSHAgentSocketWithAddress()
523
-            )
524
-            assert "query" not in agent.enabled_extensions
525
-            query_request = (
526
-                # SSH string header
527
-                b"\x00\x00\x00\x0a"
528
-                # request code: SSH_AGENTC_EXTENSION
529
-                b"\x1b"
530
-                # payload: SSH string "query"
531
-                b"\x00\x00\x00\x05query"
532
-            )
533
-            query_response = (
534
-                # SSH string header
535
-                b"\x00\x00\x00\x01"
536
-                # response code: SSH_AGENT_FAILURE
537
-                b"\x05"
538
-            )
539
-            agent.sendall(query_request)
540
-            assert agent.recv(1000) == query_response
541
-
542
-    def test_100b_query_extensions_extended(self) -> None:
543
-        """The extended agent implements a known list of extensions."""
544
-        with contextlib.ExitStack() as stack:
545
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
546
-            monkeypatch.setenv(
547
-                "SSH_AUTH_SOCK",
548
-                machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
549
-            )
550
-            agent = stack.enter_context(
551
-                machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA()
552
-            )
553
-            assert "query" in agent.enabled_extensions
554
-            query_request = (
555
-                # SSH string header
556
-                b"\x00\x00\x00\x0a"
557
-                # request code: SSH_AGENTC_EXTENSION
558
-                b"\x1b"
559
-                # payload: SSH string "query"
560
-                b"\x00\x00\x00\x05query"
561
-            )
562
-            query_response = (
563
-                # SSH string header
564
-                b"\x00\x00\x00\x40"
565
-                # response code: SSH_AGENT_EXTENSION_RESPONSE
566
-                b"\x1d"
567
-                # extension response: extension type ("query")
568
-                b"\x00\x00\x00\x05query"
569
-                # supported extension #1: query
570
-                b"\x00\x00\x00\x05query"
571
-                # supported extension #2:
572
-                # list-extended@putty.projects.tartarus.org
573
-                b"\x00\x00\x00\x29list-extended@putty.projects.tartarus.org"
574
-            )
575
-            agent.sendall(query_request)
576
-            assert agent.recv(1000) == query_response
577
-
578
-    def test_101_request_identities(self) -> None:
579
-        """The agent implements a known list of identities."""
580
-        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
581
-        with machinery.StubbedSSHAgentSocket() as agent:
582
-            query_request = (
583
-                # SSH string header
584
-                b"\x00\x00\x00\x01"
585
-                # request code: SSH_AGENTC_REQUEST_IDENTITIES
586
-                b"\x0b"
587
-            )
588
-            agent.sendall(query_request)
589
-            message_length = int.from_bytes(agent.recv(4), "big")
590
-            orig_message: bytes | bytearray = bytearray(
591
-                agent.recv(message_length)
592
-            )
593
-            assert (
594
-                _types.SSH_AGENT(orig_message[0])
595
-                == _types.SSH_AGENT.IDENTITIES_ANSWER
596
-            )
597
-            identity_count = int.from_bytes(orig_message[1:5], "big")
598
-            message = bytes(orig_message[5:])
599
-            for _ in range(identity_count):
600
-                key, message = unstring_prefix(message)
601
-                _comment, message = unstring_prefix(message)
602
-                assert key
603
-                assert key in {
604
-                    k.public_key_data for k in data.ALL_KEYS.values()
605
-                }
606
-            assert not message
607
-
608
-    @Parametrize.SUPPORTED_SSH_TEST_KEYS
609
-    def test_102_sign(
610
-        self,
611
-        ssh_test_key_type: str,
612
-        ssh_test_key: data.SSHTestKey,
613
-    ) -> None:
614
-        """The agent signs known key/message pairs."""
615
-        del ssh_test_key_type
616
-        spec = data.SSHTestKeyDeterministicSignatureClass.SPEC
617
-        assert ssh_test_key.expected_signatures[spec].signature is not None
618
-        string = ssh_agent.SSHAgentClient.string
619
-        query_request = string(
620
-            # request code: SSH_AGENTC_SIGN_REQUEST
621
-            b"\x0d"
622
-            # key: SSH string of the public key
623
-            + string(ssh_test_key.public_key_data)
624
-            # payload: SSH string of the vault UUID
625
-            + string(vault.Vault.UUID)
626
-            # signing flags (uint32, empty)
627
-            + b"\x00\x00\x00\x00"
628
-        )
629
-        query_response = string(
630
-            # response code: SSH_AGENT_SIGN_RESPONSE
631
-            b"\x0e"
632
-            # expected payload: the binary signature as recorded in the test key data structure
633
-            + string(ssh_test_key.expected_signatures[spec].signature)
634
-        )
635
-        with machinery.StubbedSSHAgentSocket() as agent:
636
-            agent.sendall(query_request)
637
-            assert agent.recv(1000) == query_response
638
-
639
-    def test_120_close_multiple(self) -> None:
640
-        """The agent can be closed repeatedly."""
641
-        with machinery.StubbedSSHAgentSocket() as agent:
642
-            pass
643
-        with machinery.StubbedSSHAgentSocket() as agent:
644
-            pass
645
-        del agent
646
-
647
-    def test_121_closed_agents_cannot_be_interacted_with(self) -> None:
648
-        """The agent can be closed repeatedly."""
649
-        with machinery.StubbedSSHAgentSocket() as agent:
650
-            pass
651
-        query_request = (
652
-            # SSH string header
653
-            b"\x00\x00\x00\x0a"
654
-            # request code: SSH_AGENTC_EXTENSION
655
-            b"\x1b"
656
-            # payload: SSH string "query"
657
-            b"\x00\x00\x00\x05query"
658
-        )
659
-        query_response = b""
660
-        with pytest.raises(
661
-            ValueError,
662
-            match=re.escape(machinery.StubbedSSHAgentSocket._SOCKET_IS_CLOSED),
663
-        ):
664
-            agent.sendall(query_request)
665
-        assert agent.recv(100) == query_response
666
-
667
-    def test_122_no_recv_without_sendall(self) -> None:
668
-        """The agent requires a message before sending a response."""
669
-        with machinery.StubbedSSHAgentSocket() as agent:  # noqa: SIM117
670
-            with pytest.raises(
671
-                AssertionError,
672
-                match=re.escape(
673
-                    machinery.StubbedSSHAgentSocket._PROTOCOL_VIOLATION
674
-                ),
675
-            ):
676
-                agent.recv(100)
677
-
678
-    @Parametrize.INVALID_SSH_AGENT_MESSAGES
679
-    def test_123_invalid_ssh_agent_messages(
680
-        self,
681
-        message: Buffer,
682
-    ) -> None:
683
-        """The agent responds with errors on invalid messages."""
684
-        query_response = (
685
-            # SSH string header
686
-            b"\x00\x00\x00\x01"
687
-            # response code: SSH_AGENT_FAILURE
688
-            b"\x05"
689
-        )
690
-        with machinery.StubbedSSHAgentSocket() as agent:
691
-            agent.sendall(message)
692
-            assert agent.recv(100) == query_response
693
-
694
-    @Parametrize.UNSUPPORTED_SSH_AGENT_MESSAGES
695
-    def test_124_unsupported_ssh_agent_messages(
696
-        self,
697
-        message: Buffer,
698
-    ) -> None:
699
-        """The agent responds with errors on unsupported messages."""
700
-        query_response = (
701
-            # SSH string header
702
-            b"\x00\x00\x00\x01"
703
-            # response code: SSH_AGENT_FAILURE
704
-            b"\x05"
705
-        )
706
-        with machinery.StubbedSSHAgentSocket() as agent:
707
-            agent.sendall(message)
708
-            assert agent.recv(100) == query_response
709
-
710
-    @Parametrize.STUBBED_AGENT_ADDRESSES
711
-    def test_125_addresses(
712
-        self,
713
-        address: str | None,
714
-        exception: type[Exception] | None,
715
-        match: str,
716
-    ) -> None:
717
-        """The agent accepts addresses."""
718
-        with contextlib.ExitStack() as stack:
719
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
720
-            if address:
721
-                monkeypatch.setenv("SSH_AUTH_SOCK", address)
722
-            else:
723
-                monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
724
-            if exception:
725
-                stack.enter_context(
726
-                    pytest.raises(exception, match=re.escape(match))
727
-                )
728
-            machinery.StubbedSSHAgentSocketWithAddress()
729
-
730
-
731
-class TestStaticFunctionality:
732
-    """Test the static functionality of the `ssh_agent` module."""
733
-
734
-    @staticmethod
735
-    def as_ssh_string(bytestring: bytes) -> bytes:
736
-        """Return an encoded SSH string from a bytestring.
737
-
738
-        This is a helper function for hypothesis data generation.
739
-
740
-        """
741
-        return int.to_bytes(len(bytestring), 4, "big") + bytestring
742
-
743
-    @staticmethod
744
-    def canonicalize1(data: bytes) -> bytes:
745
-        """Return an encoded SSH string from a bytestring.
746
-
747
-        This is a helper function for hypothesis testing.
748
-
749
-        References:
750
-
751
-          * [David R. MacIver: Another invariant to test for
752
-            encoders][DECODE_ENCODE]
753
-
754
-        [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/
755
-
756
-        """
757
-        return ssh_agent.SSHAgentClient.string(
758
-            ssh_agent.SSHAgentClient.unstring(data)
759
-        )
760
-
761
-    @staticmethod
762
-    def canonicalize2(data: bytes) -> bytes:
763
-        """Return an encoded SSH string from a bytestring.
764
-
765
-        This is a helper function for hypothesis testing.
766
-
767
-        References:
768
-
769
-          * [David R. MacIver: Another invariant to test for
770
-            encoders][DECODE_ENCODE]
771
-
772
-        [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/
773
-
774
-        """
775
-        unstringed, trailer = ssh_agent.SSHAgentClient.unstring_prefix(data)
776
-        assert not trailer
777
-        return ssh_agent.SSHAgentClient.string(unstringed)
778
-
779
-    # TODO(the-13th-letter): Re-evaluate if this check is worth keeping.
780
-    # It cannot provide true tamper-resistence, but probably appears to.
781
-    @Parametrize.PUBLIC_KEY_DATA
782
-    def test_100_key_decoding(
783
-        self,
784
-        public_key_struct: data.SSHTestKey,
785
-    ) -> None:
786
-        """The [`tests.ALL_KEYS`][] public key data looks sane."""
787
-        keydata = base64.b64decode(
788
-            public_key_struct.public_key.split(None, 2)[1]
789
-        )
790
-        assert keydata == public_key_struct.public_key_data, (
791
-            "recorded public key data doesn't match"
792
-        )
793
-
794
-    @Parametrize.SH_EXPORT_LINES
795
-    def test_190_sh_export_line_parsing(
796
-        self, line: str, env_name: str, value: str | None
797
-    ) -> None:
798
-        """[`tests.parse_sh_export_line`][] works."""
799
-        if value is not None:
800
-            assert (
801
-                callables.parse_sh_export_line(line, env_name=env_name)
802
-                == value
803
-            )
804
-        else:
805
-            with pytest.raises(ValueError, match="Cannot parse sh line:"):
806
-                callables.parse_sh_export_line(line, env_name=env_name)
807
-
808
-    def test_200_constructor_posix_no_ssh_auth_sock(
809
-        self,
810
-        skip_if_no_af_unix_support: None,
811
-    ) -> None:
812
-        """Abort if the running agent cannot be located on POSIX."""
813
-        del skip_if_no_af_unix_support
814
-        posix_handler = socketprovider.SocketProvider.resolve("posix")
815
-        with pytest.MonkeyPatch.context() as monkeypatch:
816
-            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
817
-            with pytest.raises(
818
-                KeyError, match="SSH_AUTH_SOCK environment variable"
819
-            ):
820
-                posix_handler()
821
-
822
-    @Parametrize.UINT32_INPUT
823
-    def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None:
824
-        """`uint32` encoding works."""
825
-        uint32 = ssh_agent.SSHAgentClient.uint32
826
-        assert uint32(input) == expected
827
-
828
-    @hypothesis.given(strategies.integers(min_value=0, max_value=0xFFFFFFFF))
829
-    @hypothesis.example(0xDEADBEEF).via("manual, pre-hypothesis example")
830
-    def test_210a_uint32_from_number(self, num: int) -> None:
831
-        """`uint32` encoding works, starting from numbers."""
832
-        uint32 = ssh_agent.SSHAgentClient.uint32
833
-        assert int.from_bytes(uint32(num), "big", signed=False) == num
834
-
835
-    @hypothesis.given(strategies.binary(min_size=4, max_size=4))
836
-    @hypothesis.example(b"\xde\xad\xbe\xef").via(
837
-        "manual, pre-hypothesis example"
838
-    )
839
-    def test_210b_uint32_from_bytestring(self, bytestring: bytes) -> None:
840
-        """`uint32` encoding works, starting from length four byte strings."""
841
-        uint32 = ssh_agent.SSHAgentClient.uint32
842
-        assert (
843
-            uint32(int.from_bytes(bytestring, "big", signed=False))
844
-            == bytestring
845
-        )
846
-
847
-    @Parametrize.SSH_STRING_INPUT
848
-    def test_211_string(
849
-        self, input: bytes | bytearray, expected: bytes | bytearray
850
-    ) -> None:
851
-        """SSH string encoding works."""
852
-        string = ssh_agent.SSHAgentClient.string
853
-        assert bytes(string(input)) == expected
854
-
855
-    @hypothesis.given(strategies.binary(max_size=0x0001FFFF))
856
-    @hypothesis.example(b"DEADBEEF" * 10000).via(
857
-        "manual, pre-hypothesis example with highest order bit set"
858
-    )
859
-    def test_211a_string_from_bytestring(self, bytestring: bytes) -> None:
860
-        """SSH string encoding works, starting from a byte string."""
861
-        res = ssh_agent.SSHAgentClient.string(bytestring)
862
-        assert res.startswith((b"\x00\x00", b"\x00\x01"))
863
-        assert int.from_bytes(res[:4], "big", signed=False) == len(bytestring)
864
-        assert res[4:] == bytestring
865
-
866
-    @Parametrize.SSH_UNSTRING_INPUT
867
-    def test_212_unstring(
868
-        self, input: bytes | bytearray, expected: bytes | bytearray
869
-    ) -> None:
870
-        """SSH string decoding works."""
871
-        unstring = ssh_agent.SSHAgentClient.unstring
872
-        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
873
-        assert bytes(unstring(input)) == expected
874
-        assert tuple(bytes(x) for x in unstring_prefix(input)) == (
875
-            expected,
876
-            b"",
877
-        )
878
-
879
-    @hypothesis.given(strategies.binary(max_size=0x00FFFFFF))
880
-    @hypothesis.example(b"\x00\x00\x00\x07ssh-rsa").via(
881
-        "manual, pre-hypothesis example to attempt to detect double-decoding"
882
-    )
883
-    @hypothesis.example(b"\x00\x00\x00\x01").via(
884
-        "detect no-op encoding via ill-formed SSH string"
885
-    )
886
-    def test_212a_unstring_of_string_of_data(self, bytestring: bytes) -> None:
887
-        """SSH string decoding of encoded SSH strings works.
888
-
889
-        References:
890
-
891
-          * [David R. MacIver: The Encode/Decode invariant][ENCODE_DECODE]
892
-
893
-        [ENCODE_DECODE]: https://hypothesis.works/articles/encode-decode-invariant/
894
-
895
-        """
896
-        string = ssh_agent.SSHAgentClient.string
897
-        unstring = ssh_agent.SSHAgentClient.unstring
898
-        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
899
-        encoded = string(bytestring)
900
-        assert unstring(encoded) == bytestring
901
-        assert unstring_prefix(encoded) == (bytestring, b"")
902
-        trailing_data = b"  trailing data"
903
-        encoded2 = string(bytestring) + trailing_data
904
-        assert unstring_prefix(encoded2) == (bytestring, trailing_data)
905
-
906
-    @hypothesis.given(
907
-        strategies.binary(max_size=0x00FFFFFF).map(
908
-            # Scoping issues, and the fact that staticmethod objects
909
-            # (before class finalization) are not callable, necessitate
910
-            # wrapping this staticmethod call in a lambda.
911
-            lambda x: TestStaticFunctionality.as_ssh_string(x)  # noqa: PLW0108
912
-        ),
913
-    )
914
-    def test_212b_string_of_unstring_of_data(self, encoded: bytes) -> None:
915
-        """SSH string decoding of encoded SSH strings works.
916
-
917
-        References:
918
-
919
-          * [David R. MacIver: Another invariant to test for
920
-            encoders][DECODE_ENCODE]
921
-
922
-        [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/
923
-
924
-        """
925
-        canonical_functions = [self.canonicalize1, self.canonicalize2]
926
-        for canon1 in canonical_functions:
927
-            for canon2 in canonical_functions:
928
-                assert canon1(encoded) == canon2(encoded)
929
-                assert canon1(canon2(encoded)) == canon1(encoded)
930
-
931
-    def test_220_registry_resolve(
932
-        self,
933
-    ) -> None:
934
-        """Resolving entries in the socket provider registry works."""
935
-        registry = socketprovider.SocketProvider.registry
936
-        resolve = socketprovider.SocketProvider.resolve
937
-        lookup = socketprovider.SocketProvider.lookup
938
-        with pytest.MonkeyPatch.context() as monkeypatch:
939
-            monkeypatch.setitem(registry, "stub_agent", None)
940
-            assert callable(lookup("native"))
941
-            assert callable(resolve("native"))
942
-            assert lookup("stub_agent") is None
943
-            with pytest.raises(NotImplementedError):
944
-                resolve("stub_agent")
945
-
946
-    @Parametrize.RESOLVE_CHAINS
947
-    def test_221_registry_resolve_chains(
948
-        self,
949
-        terminal: Literal["unimplemented", "alias", "callable"],
950
-        chain: list[str],
951
-    ) -> None:
952
-        """Resolving a chain of providers works."""
953
-        registry = socketprovider.SocketProvider.registry
954
-        resolve = socketprovider.SocketProvider.resolve
955
-        lookup = socketprovider.SocketProvider.lookup
956
-        try:
957
-            implementation = resolve("native")
958
-        except NotImplementedError:  # pragma: no cover
959
-            pytest.fail("Native SSH agent socket provider is unavailable?!")
960
-        # TODO(the-13th-letter): Rewrite using structural pattern matching.
961
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
962
-        target = (
963
-            None
964
-            if terminal == "unimplemented"
965
-            else "native"
966
-            if terminal == "alias"
967
-            else implementation
968
-        )
969
-        with pytest.MonkeyPatch.context() as monkeypatch:
970
-            for link in chain:
971
-                monkeypatch.setitem(registry, link, target)
972
-                target = link
973
-            for link in chain:
974
-                assert lookup(link) == (
975
-                    implementation if terminal != "unimplemented" else None
976
-                )
977
-                if terminal == "unimplemented":
978
-                    with pytest.raises(NotImplementedError):
979
-                        resolve(link)
980
-                else:
981
-                    assert resolve(link) == implementation
982
-
983
-    @hypothesis.given(
984
-        terminal=strategies.sampled_from([
985
-            "unimplemented",
986
-            "alias",
987
-            "callable",
988
-        ]),
989
-        chain=strategies.lists(
990
-            strategies.sampled_from([
991
-                "c1",
992
-                "c2",
993
-                "c3",
994
-                "c4",
995
-                "c5",
996
-                "c6",
997
-                "c7",
998
-                "c8",
999
-                "c9",
1000
-                "c10",
1001
-            ]),
1002
-            min_size=1,
1003
-            unique=True,
1004
-        ),
1005
-    )
1006
-    def test_221a_registry_resolve_chains(
1007
-        self,
1008
-        terminal: Literal["unimplemented", "alias", "callable"],
1009
-        chain: list[str],
1010
-    ) -> None:
1011
-        """Resolving a chain of providers works."""
1012
-        registry = socketprovider.SocketProvider.registry
1013
-        resolve = socketprovider.SocketProvider.resolve
1014
-        lookup = socketprovider.SocketProvider.lookup
1015
-        try:
1016
-            implementation = resolve("native")
1017
-        except NotImplementedError:  # pragma: no cover
1018
-            hypothesis.note(f"{registry = }")
1019
-            pytest.fail("Native SSH agent socket provider is unavailable?!")
1020
-        # TODO(the-13th-letter): Rewrite using structural pattern matching.
1021
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1022
-        target = (
1023
-            None
1024
-            if terminal == "unimplemented"
1025
-            else "native"
1026
-            if terminal == "alias"
1027
-            else implementation
1028
-        )
1029
-        with pytest.MonkeyPatch.context() as monkeypatch:
1030
-            for link in chain:
1031
-                monkeypatch.setitem(registry, link, target)
1032
-                target = link
1033
-            for link in chain:
1034
-                assert lookup(link) == (
1035
-                    implementation if terminal != "unimplemented" else None
1036
-                )
1037
-                if terminal == "unimplemented":
1038
-                    with pytest.raises(NotImplementedError):
1039
-                        resolve(link)
1040
-                else:
1041
-                    assert resolve(link) == implementation
1042
-
1043
-    @Parametrize.GOOD_ENTRY_POINTS
1044
-    def test_230_find_all_socket_providers(
1045
-        self,
1046
-        additional_entry_points: list[importlib.metadata.EntryPoint],
1047
-    ) -> None:
1048
-        """Finding all SSH agent socket providers works."""
1049
-        resolve = socketprovider.SocketProvider.resolve
1050
-        old_registry = socketprovider.SocketProvider.registry
1051
-        with pytest_machinery.faked_entry_point_list(
1052
-            additional_entry_points, remove_conflicting_entries=False
1053
-        ) as names:
1054
-            socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
1055
-            for name in names:
1056
-                assert name in socketprovider.SocketProvider.registry
1057
-                assert resolve(name) in {
1058
-                    callables.provider_entry_provider,
1059
-                    *old_registry.values(),
1060
-                }
1061
-
1062
-    @Parametrize.BAD_ENTRY_POINTS
1063
-    def test_231_find_all_socket_providers_errors(
1064
-        self,
1065
-        additional_entry_points: list[importlib.metadata.EntryPoint],
1066
-    ) -> None:
1067
-        """Finding faulty SSH agent socket providers raises errors."""
1068
-        with contextlib.ExitStack() as stack:
1069
-            stack.enter_context(
1070
-                pytest_machinery.faked_entry_point_list(
1071
-                    additional_entry_points, remove_conflicting_entries=False
1072
-                )
1073
-            )
1074
-            stack.enter_context(pytest.raises(AssertionError))
1075
-            socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
1076
-
1077
-    @Parametrize.UINT32_EXCEPTIONS
1078
-    def test_310_uint32_exceptions(
1079
-        self, input: int, exc_type: type[Exception], exc_pattern: str
1080
-    ) -> None:
1081
-        """`uint32` encoding fails for out-of-bound values."""
1082
-        uint32 = ssh_agent.SSHAgentClient.uint32
1083
-        with pytest.raises(exc_type, match=exc_pattern):
1084
-            uint32(input)
1085
-
1086
-    @Parametrize.SSH_STRING_EXCEPTIONS
1087
-    def test_311_string_exceptions(
1088
-        self, input: Any, exc_type: type[Exception], exc_pattern: str
1089
-    ) -> None:
1090
-        """SSH string encoding fails for non-strings."""
1091
-        string = ssh_agent.SSHAgentClient.string
1092
-        with pytest.raises(exc_type, match=exc_pattern):
1093
-            string(input)
1094
-
1095
-    @Parametrize.SSH_UNSTRING_EXCEPTIONS
1096
-    def test_312_unstring_exceptions(
1097
-        self,
1098
-        input: bytes | bytearray,
1099
-        exc_type: type[Exception],
1100
-        exc_pattern: str,
1101
-        has_trailer: bool,
1102
-        parts: tuple[bytes | bytearray, bytes | bytearray] | None,
1103
-    ) -> None:
1104
-        """SSH string decoding fails for invalid values."""
1105
-        unstring = ssh_agent.SSHAgentClient.unstring
1106
-        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
1107
-        with pytest.raises(exc_type, match=exc_pattern):
1108
-            unstring(input)
1109
-        if has_trailer:
1110
-            assert tuple(bytes(x) for x in unstring_prefix(input)) == parts
1111
-        else:
1112
-            with pytest.raises(exc_type, match=exc_pattern):
1113
-                unstring_prefix(input)
1114
-
1115
-    def test_320_registry_already_registered(
1116
-        self,
1117
-    ) -> None:
1118
-        """The registry forbids overwriting entries."""
1119
-        registry = socketprovider.SocketProvider.registry.copy()
1120
-        resolve = socketprovider.SocketProvider.resolve
1121
-        register = socketprovider.SocketProvider.register
1122
-        the_annoying_os = resolve("the_annoying_os")
1123
-        posix = resolve("posix")
1124
-        with pytest.MonkeyPatch.context() as monkeypatch:
1125
-            monkeypatch.setattr(
1126
-                socketprovider.SocketProvider, "registry", registry
1127
-            )
1128
-            register("posix")(posix)
1129
-            register("the_annoying_os")(the_annoying_os)
1130
-            with pytest.raises(ValueError, match="already registered"):
1131
-                register("posix")(the_annoying_os)
1132
-            with pytest.raises(ValueError, match="already registered"):
1133
-                register("the_annoying_os")(posix)
1134
-            with pytest.raises(ValueError, match="already registered"):
1135
-                register("posix", "the_annoying_os_named_pipe")(posix)
1136
-            with pytest.raises(ValueError, match="already registered"):
1137
-                register("the_annoying_os", "unix_domain")(the_annoying_os)
1138
-
1139
-    def test_321_registry_resolve_non_existant_entries(
1140
-        self,
1141
-    ) -> None:
1142
-        """Resolving a non-existant entry fails."""
1143
-        new_registry = {
1144
-            "posix": socketprovider.SocketProvider.registry["posix"],
1145
-            "the_annoying_os": socketprovider.SocketProvider.registry[
1146
-                "the_annoying_os"
1147
-            ],
1148
-        }
1149
-        with pytest.MonkeyPatch.context() as monkeypatch:
1150
-            monkeypatch.setattr(
1151
-                socketprovider.SocketProvider, "registry", new_registry
1152
-            )
1153
-            with pytest.raises(socketprovider.NoSuchProviderError):
1154
-                socketprovider.SocketProvider.resolve("native")
1155
-
1156
-    def test_322_registry_register_new_entry(
1157
-        self,
1158
-    ) -> None:
1159
-        """Registering new entries works."""
1160
-
1161
-        def socket_provider() -> _types.SSHAgentSocket:
1162
-            raise AssertionError
1163
-
1164
-        names = ["spam", "ham", "eggs", "parrot"]
1165
-        new_registry = {
1166
-            "posix": socketprovider.SocketProvider.registry["posix"],
1167
-            "the_annoying_os": socketprovider.SocketProvider.registry[
1168
-                "the_annoying_os"
1169
-            ],
1170
-        }
1171
-        with pytest.MonkeyPatch.context() as monkeypatch:
1172
-            monkeypatch.setattr(
1173
-                socketprovider.SocketProvider, "registry", new_registry
1174
-            )
1175
-            assert not any(
1176
-                map(socketprovider.SocketProvider.registry.__contains__, names)
1177
-            )
1178
-            assert (
1179
-                socketprovider.SocketProvider.register(*names)(socket_provider)
1180
-                is socket_provider
1181
-            )
1182
-            assert all(
1183
-                map(socketprovider.SocketProvider.registry.__contains__, names)
1184
-            )
1185
-            assert all([
1186
-                socketprovider.SocketProvider.resolve(n) is socket_provider
1187
-                for n in names
1188
-            ])
1189
-
1190
-    @Parametrize.EXISTING_REGISTRY_ENTRIES
1191
-    def test_323_registry_register_old_entry(
1192
-        self,
1193
-        existing: str,
1194
-    ) -> None:
1195
-        """Registering old entries works."""
1196
-
1197
-        provider = socketprovider.SocketProvider.resolve(existing)
1198
-        new_registry = {
1199
-            "posix": socketprovider.SocketProvider.registry["posix"],
1200
-            "the_annoying_os": socketprovider.SocketProvider.registry[
1201
-                "the_annoying_os"
1202
-            ],
1203
-            "unix_domain": "posix",
1204
-            "the_annoying_os_named_pipe": "the_annoying_os",
1205
-        }
1206
-        names = [
1207
-            k
1208
-            for k, v in socketprovider.SocketProvider.registry.items()
1209
-            if v == existing
1210
-        ]
1211
-        with pytest.MonkeyPatch.context() as monkeypatch:
1212
-            monkeypatch.setattr(
1213
-                socketprovider.SocketProvider, "registry", new_registry
1214
-            )
1215
-            assert not all(
1216
-                map(socketprovider.SocketProvider.registry.__contains__, names)
1217
-            )
1218
-            assert (
1219
-                socketprovider.SocketProvider.register(existing, *names)(
1220
-                    provider
1221
-                )
1222
-                is provider
1223
-            )
1224
-            assert all(
1225
-                map(socketprovider.SocketProvider.registry.__contains__, names)
1226
-            )
1227
-            assert all([
1228
-                socketprovider.SocketProvider.resolve(n) is provider
1229
-                for n in [existing, *names]
1230
-            ])
1231
-
1232
-
1233
-class TestAgentInteraction:
1234
-    """Test actually talking to the SSH agent."""
1235
-
1236
-    @Parametrize.SUPPORTED_SSH_TEST_KEYS
1237
-    def test_200_sign_data_via_agent(
1238
-        self,
1239
-        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1240
-        ssh_test_key_type: str,
1241
-        ssh_test_key: data.SSHTestKey,
1242
-    ) -> None:
1243
-        """Signing data with specific SSH keys works.
1244
-
1245
-        Single tests may abort early (skip) if the indicated key is not
1246
-        loaded in the agent.  Presumably this means the key type is
1247
-        unsupported.
1248
-
1249
-        """
1250
-        client = ssh_agent_client_with_test_keys_loaded
1251
-        key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
1252
-        public_key_data = ssh_test_key.public_key_data
1253
-        assert (
1254
-            data.SSHTestKeyDeterministicSignatureClass.SPEC
1255
-            in ssh_test_key.expected_signatures
1256
-        )
1257
-        sig = ssh_test_key.expected_signatures[
1258
-            data.SSHTestKeyDeterministicSignatureClass.SPEC
1259
-        ]
1260
-        expected_signature = sig.signature
1261
-        derived_passphrase = sig.derived_passphrase
1262
-        if public_key_data not in key_comment_pairs:  # pragma: no cover
1263
-            pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded")
1264
-        signature = bytes(
1265
-            client.sign(payload=vault.Vault.UUID, key=public_key_data)
1266
-        )
1267
-        assert signature == expected_signature, (
1268
-            f"SSH signature mismatch ({ssh_test_key_type})"
1269
-        )
1270
-        signature2 = bytes(
1271
-            client.sign(payload=vault.Vault.UUID, key=public_key_data)
1272
-        )
1273
-        assert signature2 == expected_signature, (
1274
-            f"SSH signature mismatch ({ssh_test_key_type})"
1275
-        )
1276
-        assert (
1277
-            vault.Vault.phrase_from_key(public_key_data, conn=client)
1278
-            == derived_passphrase
1279
-        ), f"SSH signature mismatch ({ssh_test_key_type})"
1280
-
1281
-    @Parametrize.UNSUITABLE_SSH_TEST_KEYS
1282
-    def test_201_sign_data_via_agent_unsupported(
1283
-        self,
1284
-        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1285
-        ssh_test_key_type: str,
1286
-        ssh_test_key: data.SSHTestKey,
1287
-    ) -> None:
1288
-        """Using an unsuitable key with [`vault.Vault`][] fails.
1289
-
1290
-        Single tests may abort early (skip) if the indicated key is not
1291
-        loaded in the agent.  Presumably this means the key type is
1292
-        unsupported.  Single tests may also abort early if the agent
1293
-        ensures that the generally unsuitable key is actually suitable
1294
-        under this agent.
1295
-
1296
-        """
1297
-        client = ssh_agent_client_with_test_keys_loaded
1298
-        key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
1299
-        public_key_data = ssh_test_key.public_key_data
1300
-        if public_key_data not in key_comment_pairs:  # pragma: no cover
1301
-            pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded")
1302
-        assert not vault.Vault.is_suitable_ssh_key(
1303
-            public_key_data, client=None
1304
-        ), f"Expected {ssh_test_key_type} key to be unsuitable in general"
1305
-        if vault.Vault.is_suitable_ssh_key(public_key_data, client=client):
1306
-            pytest.skip(
1307
-                f"agent automatically ensures {ssh_test_key_type} key is suitable"
1308
-            )
1309
-        with pytest.raises(ValueError, match="unsuitable SSH key"):
1310
-            vault.Vault.phrase_from_key(public_key_data, conn=client)
1311
-
1312
-    @Parametrize.SSH_KEY_SELECTION
1313
-    def test_210_ssh_key_selector(
1314
-        self,
1315
-        monkeypatch: pytest.MonkeyPatch,
1316
-        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1317
-        key: bytes,
1318
-        single: bool,
1319
-    ) -> None:
1320
-        """The key selector presents exactly the suitable keys.
1321
-
1322
-        "Suitable" here means suitability for this SSH agent
1323
-        specifically.
1324
-
1325
-        """
1326
-        client = ssh_agent_client_with_test_keys_loaded
1327
-
1328
-        def key_is_suitable(key: bytes) -> bool:
1329
-            """Stub out [`vault.Vault.key_is_suitable`][]."""
1330
-            always = {v.public_key_data for v in data.SUPPORTED_KEYS.values()}
1331
-            dsa = {
1332
-                v.public_key_data
1333
-                for k, v in data.UNSUITABLE_KEYS.items()
1334
-                if k.startswith(("dsa", "ecdsa"))
1335
-            }
1336
-            return key in always or (
1337
-                client.has_deterministic_dsa_signatures() and key in dsa
1338
-            )
1339
-
1340
-        # TODO(the-13th-letter): Handle the unlikely(?) case that only
1341
-        # one test key is loaded, but `single` is False.  Rename the
1342
-        # `index` variable to `input`, store the `input` in there, and
1343
-        # make the definition of `text` in the else block dependent on
1344
-        # `n` being singular or non-singular.
1345
-        if single:
1346
-            monkeypatch.setattr(
1347
-                ssh_agent.SSHAgentClient,
1348
-                "list_keys",
1349
-                callables.list_keys_singleton,
1350
-            )
1351
-            keys = [
1352
-                pair.key
1353
-                for pair in callables.list_keys_singleton()
1354
-                if key_is_suitable(pair.key)
1355
-            ]
1356
-            index = "1"
1357
-            text = "Use this key? yes\n"
1358
-        else:
1359
-            monkeypatch.setattr(
1360
-                ssh_agent.SSHAgentClient,
1361
-                "list_keys",
1362
-                callables.list_keys,
1363
-            )
1364
-            keys = [
1365
-                pair.key
1366
-                for pair in callables.list_keys()
1367
-                if key_is_suitable(pair.key)
1368
-            ]
1369
-            index = str(1 + keys.index(key))
1370
-            n = len(keys)
1371
-            text = f"Your selection? (1-{n}, leave empty to abort): {index}\n"
1372
-        b64_key = base64.standard_b64encode(key).decode("ASCII")
1373
-
1374
-        @click.command()
1375
-        def driver() -> None:
1376
-            """Call [`cli_helpers.select_ssh_key`][] directly, as a command."""
1377
-            key = cli_helpers.select_ssh_key(client)
1378
-            click.echo(base64.standard_b64encode(key).decode("ASCII"))
1379
-
1380
-        # TODO(the-13th-letter): (Continued from above.)  Update input
1381
-        # data to use `index`/`input` directly and unconditionally.
1382
-        runner = machinery.CliRunner(mix_stderr=True)
1383
-        result = runner.invoke(
1384
-            driver,
1385
-            [],
1386
-            input=("yes\n" if single else f"{index}\n"),
1387
-            catch_exceptions=True,
1388
-        )
1389
-        for snippet in ("Suitable SSH keys:\n", text, f"\n{b64_key}\n"):
1390
-            assert result.clean_exit(output=snippet), "expected clean exit"
1391
-
1392
-    def test_300_constructor_bad_running_agent(
1393
-        self,
1394
-        running_ssh_agent: data.RunningSSHAgentInfo,
1395
-    ) -> None:
1396
-        """Fail if the agent address is invalid."""
1397
-        with pytest.MonkeyPatch.context() as monkeypatch:
1398
-            new_socket_name = (
1399
-                running_ssh_agent.socket + "~"
1400
-                if isinstance(running_ssh_agent.socket, str)
1401
-                else "<invalid//address>"
1402
-            )
1403
-            monkeypatch.setenv("SSH_AUTH_SOCK", new_socket_name)
1404
-            with pytest.raises(OSError):  # noqa: PT011
1405
-                ssh_agent.SSHAgentClient()
1406
-
1407
-    def test_301_constructor_no_af_unix_support(self) -> None:
1408
-        """Fail without [`socket.AF_UNIX`][] support."""
1409
-        assert "posix" in socketprovider.SocketProvider.registry
1410
-        with pytest.MonkeyPatch.context() as monkeypatch:
1411
-            monkeypatch.setenv("SSH_AUTH_SOCK", "the value doesn't matter")
1412
-            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
1413
-            with pytest.raises(
1414
-                NotImplementedError,
1415
-                match="UNIX domain sockets",
1416
-            ):
1417
-                ssh_agent.SSHAgentClient(socket="posix")
1418
-
1419
-    def test_302_no_ssh_agent_socket_provider_available(
1420
-        self,
1421
-    ) -> None:
1422
-        """Fail if no SSH agent socket provider is available."""
1423
-        with pytest.MonkeyPatch.context() as monkeypatch:
1424
-            monkeypatch.setitem(
1425
-                socketprovider.SocketProvider.registry, "stub_agent", None
1426
-            )
1427
-            with pytest.raises(ExceptionGroup) as excinfo:
1428
-                ssh_agent.SSHAgentClient(
1429
-                    socket=["stub_agent", "stub_agent", "stub_agent"]
1430
-                )
1431
-            assert all([
1432
-                isinstance(e, NotImplementedError)
1433
-                for e in excinfo.value.exceptions
1434
-            ])
1435
-
1436
-    def test_303_explicit_socket(
1437
-        self,
1438
-        spawn_ssh_agent: data.SpawnedSSHAgentInfo,
1439
-    ) -> None:
1440
-        conn = spawn_ssh_agent.client._connection
1441
-        ssh_agent.SSHAgentClient(socket=conn)
1442
-
1443
-    @Parametrize.TRUNCATED_AGENT_RESPONSES
1444
-    def test_310_truncated_server_response(
1445
-        self,
1446
-        running_ssh_agent: data.RunningSSHAgentInfo,
1447
-        response: bytes,
1448
-    ) -> None:
1449
-        """Fail on truncated responses from the SSH agent."""
1450
-        del running_ssh_agent
1451
-        client = ssh_agent.SSHAgentClient()
1452
-        response_stream = io.BytesIO(response)
1453
-
1454
-        class PseudoSocket:
1455
-            def sendall(self, *args: Any, **kwargs: Any) -> Any:  # noqa: ARG002
1456
-                return None
1457
-
1458
-            def recv(self, *args: Any, **kwargs: Any) -> Any:
1459
-                return response_stream.read(*args, **kwargs)
1460
-
1461
-        pseudo_socket = PseudoSocket()
1462
-        with pytest.MonkeyPatch.context() as monkeypatch:
1463
-            monkeypatch.setattr(client, "_connection", pseudo_socket)
1464
-            with pytest.raises(EOFError):
1465
-                client.request(255, b"")
1466
-
1467
-    @Parametrize.LIST_KEYS_ERROR_RESPONSES
1468
-    def test_320_list_keys_error_responses(
1469
-        self,
1470
-        running_ssh_agent: data.RunningSSHAgentInfo,
1471
-        response_code: _types.SSH_AGENT,
1472
-        response: bytes | bytearray,
1473
-        exc_type: type[Exception],
1474
-        exc_pattern: str,
1475
-    ) -> None:
1476
-        """Fail on problems during key listing.
1477
-
1478
-        Known problems:
1479
-
1480
-          - The agent refuses, or otherwise indicates the operation
1481
-            failed.
1482
-          - The agent response is truncated.
1483
-          - The agent response is overlong.
1484
-
1485
-        """
1486
-        del running_ssh_agent
1487
-
1488
-        passed_response_code = response_code
1489
-
1490
-        # TODO(the-13th-letter): Extract this mock function into a common
1491
-        # top-level "request" mock function.
1492
-        def request(
1493
-            request_code: int | _types.SSH_AGENTC,
1494
-            payload: bytes | bytearray,
1495
-            /,
1496
-            *,
1497
-            response_code: Iterable[int | _types.SSH_AGENT]
1498
-            | int
1499
-            | _types.SSH_AGENT
1500
-            | None = None,
1501
-        ) -> tuple[int, bytes | bytearray] | bytes | bytearray:
1502
-            del request_code
1503
-            del payload
1504
-            if isinstance(  # pragma: no branch
1505
-                response_code, (int, _types.SSH_AGENT)
1506
-            ):
1507
-                response_code = frozenset({response_code})
1508
-            if response_code is not None:  # pragma: no branch
1509
-                response_code = frozenset({
1510
-                    c if isinstance(c, int) else c.value for c in response_code
1511
-                })
1512
-
1513
-            if not response_code:  # pragma: no cover
1514
-                return (passed_response_code.value, response)
1515
-            if passed_response_code.value not in response_code:
1516
-                raise ssh_agent.SSHAgentFailedError(
1517
-                    passed_response_code.value, response
1518
-                )
1519
-            return response
1520
-
1521
-        with pytest.MonkeyPatch.context() as monkeypatch:
1522
-            client = ssh_agent.SSHAgentClient()
1523
-            monkeypatch.setattr(client, "request", request)
1524
-            with pytest.raises(exc_type, match=exc_pattern):
1525
-                client.list_keys()
1526
-
1527
-    @Parametrize.SIGN_ERROR_RESPONSES
1528
-    def test_330_sign_error_responses(
1529
-        self,
1530
-        running_ssh_agent: data.RunningSSHAgentInfo,
1531
-        key: bytes | bytearray,
1532
-        check: bool,
1533
-        response_code: _types.SSH_AGENT,
1534
-        response: bytes | bytearray,
1535
-        exc_type: type[Exception],
1536
-        exc_pattern: str,
1537
-    ) -> None:
1538
-        """Fail on problems during signing.
1539
-
1540
-        Known problems:
1541
-
1542
-          - The key is not loaded into the agent.
1543
-          - The agent refuses, or otherwise indicates the operation
1544
-            failed.
1545
-
1546
-        """
1547
-        del running_ssh_agent
1548
-        passed_response_code = response_code
1549
-
1550
-        # TODO(the-13th-letter): Extract this mock function into a common
1551
-        # top-level "request" mock function.
1552
-        def request(
1553
-            request_code: int | _types.SSH_AGENTC,
1554
-            payload: bytes | bytearray,
1555
-            /,
1556
-            *,
1557
-            response_code: Iterable[int | _types.SSH_AGENT]
1558
-            | int
1559
-            | _types.SSH_AGENT
1560
-            | None = None,
1561
-        ) -> tuple[int, bytes | bytearray] | bytes | bytearray:
1562
-            del request_code
1563
-            del payload
1564
-            if isinstance(  # pragma: no branch
1565
-                response_code, (int, _types.SSH_AGENT)
1566
-            ):
1567
-                response_code = frozenset({response_code})
1568
-            if response_code is not None:  # pragma: no branch
1569
-                response_code = frozenset({
1570
-                    c if isinstance(c, int) else c.value for c in response_code
1571
-                })
1572
-
1573
-            if not response_code:  # pragma: no cover
1574
-                return (passed_response_code.value, response)
1575
-            if (
1576
-                passed_response_code.value not in response_code
1577
-            ):  # pragma: no branch
1578
-                raise ssh_agent.SSHAgentFailedError(
1579
-                    passed_response_code.value, response
1580
-                )
1581
-            return response  # pragma: no cover
1582
-
1583
-        with pytest.MonkeyPatch.context() as monkeypatch:
1584
-            client = ssh_agent.SSHAgentClient()
1585
-            monkeypatch.setattr(client, "request", request)
1586
-            Pair = _types.SSHKeyCommentPair  # noqa: N806
1587
-            com = b"no comment"
1588
-            loaded_keys = [
1589
-                Pair(v.public_key_data, com).toreadonly()
1590
-                for v in data.SUPPORTED_KEYS.values()
1591
-            ]
1592
-            monkeypatch.setattr(client, "list_keys", lambda: loaded_keys)
1593
-            with pytest.raises(exc_type, match=exc_pattern):
1594
-                client.sign(key, b"abc", check_if_key_loaded=check)
1595
-
1596
-    @Parametrize.REQUEST_ERROR_RESPONSES
1597
-    def test_340_request_error_responses(
1598
-        self,
1599
-        running_ssh_agent: data.RunningSSHAgentInfo,
1600
-        request_code: _types.SSH_AGENTC,
1601
-        response_code: _types.SSH_AGENT,
1602
-        exc_type: type[Exception],
1603
-        exc_pattern: str,
1604
-    ) -> None:
1605
-        """Fail on problems during signing.
1606
-
1607
-        Known problems:
1608
-
1609
-          - The key is not loaded into the agent.
1610
-          - The agent refuses, or otherwise indicates the operation
1611
-            failed.
1612
-
1613
-        """
1614
-        del running_ssh_agent
1615
-
1616
-        # TODO(the-13th-letter): Rewrite using parenthesized
1617
-        # with-statements.
1618
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1619
-        with contextlib.ExitStack() as stack:
1620
-            stack.enter_context(pytest.raises(exc_type, match=exc_pattern))
1621
-            client = stack.enter_context(ssh_agent.SSHAgentClient())
1622
-            client.request(request_code, b"", response_code=response_code)
1623
-
1624
-    @Parametrize.QUERY_EXTENSIONS_MALFORMED_RESPONSES
1625
-    def test_350_query_extensions_malformed_responses(
1626
-        self,
1627
-        monkeypatch: pytest.MonkeyPatch,
1628
-        running_ssh_agent: data.RunningSSHAgentInfo,
1629
-        response_data: bytes,
1630
-    ) -> None:
1631
-        """Fail on malformed responses while querying extensions."""
1632
-        del running_ssh_agent
1633
-
1634
-        # TODO(the-13th-letter): Extract this mock function into a common
1635
-        # top-level "request" mock function after removing the
1636
-        # payload-specific parts.
1637
-        def request(
1638
-            code: int | _types.SSH_AGENTC,
1639
-            payload: Buffer,
1640
-            /,
1641
-            *,
1642
-            response_code: (
1643
-                Iterable[_types.SSH_AGENT | int]
1644
-                | _types.SSH_AGENT
1645
-                | int
1646
-                | None
1647
-            ) = None,
1648
-        ) -> tuple[int, bytes] | bytes:
1649
-            request_codes = {
1650
-                _types.SSH_AGENTC.EXTENSION,
1651
-                _types.SSH_AGENTC.EXTENSION.value,
1652
-            }
1653
-            assert code in request_codes
1654
-            response_codes = {
1655
-                _types.SSH_AGENT.EXTENSION_RESPONSE,
1656
-                _types.SSH_AGENT.EXTENSION_RESPONSE.value,
1657
-                _types.SSH_AGENT.SUCCESS,
1658
-                _types.SSH_AGENT.SUCCESS.value,
1659
-            }
1660
-            assert payload == b"\x00\x00\x00\x05query"
1661
-            if response_code is None:  # pragma: no cover
1662
-                return (
1663
-                    _types.SSH_AGENT.EXTENSION_RESPONSE.value,
1664
-                    response_data,
1665
-                )
1666
-            if isinstance(  # pragma: no cover
1667
-                response_code, (_types.SSH_AGENT, int)
1668
-            ):
1669
-                assert response_code in response_codes
1670
-                return response_data
1671
-            for single_code in response_code:  # pragma: no cover
1672
-                assert single_code in response_codes
1673
-            return response_data  # pragma: no cover
1674
-
1675
-        # TODO(the-13th-letter): Rewrite using parenthesized
1676
-        # with-statements.
1677
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1678
-        with contextlib.ExitStack() as stack:
1679
-            monkeypatch2 = stack.enter_context(monkeypatch.context())
1680
-            client = stack.enter_context(ssh_agent.SSHAgentClient())
1681
-            monkeypatch2.setattr(client, "request", request)
1682
-            with pytest.raises(
1683
-                RuntimeError,
1684
-                match=r"Malformed response|does not match request",
1685
-            ):
1686
-                client.query_extensions()
... ...
@@ -0,0 +1,1686 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Test OpenSSH key loading and signing."""
6
+
7
+from __future__ import annotations
8
+
9
+import base64
10
+import contextlib
11
+import errno
12
+import importlib.metadata
13
+import io
14
+import os
15
+import pathlib
16
+import re
17
+import socket
18
+import sys
19
+import types
20
+from typing import TYPE_CHECKING
21
+
22
+import click
23
+import click.testing
24
+import hypothesis
25
+import pytest
26
+from hypothesis import strategies
27
+
28
+from derivepassphrase import _types, ssh_agent, vault
29
+from derivepassphrase._internals import cli_helpers
30
+from derivepassphrase.ssh_agent import socketprovider
31
+from tests import data, machinery
32
+from tests.data import callables
33
+from tests.machinery import pytest as pytest_machinery
34
+
35
+if TYPE_CHECKING:
36
+    from collections.abc import Iterable
37
+
38
+    from typing_extensions import Any, Buffer, Literal
39
+
40
+if sys.version_info < (3, 11):
41
+    from exceptiongroup import ExceptionGroup
42
+
43
+
44
+class Parametrize(types.SimpleNamespace):
45
+    BAD_ENTRY_POINTS = pytest.mark.parametrize(
46
+        "additional_entry_points",
47
+        [
48
+            pytest.param(
49
+                [
50
+                    importlib.metadata.EntryPoint(
51
+                        name=data.faulty_entry_callable.key,
52
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
53
+                        value="tests.data: faulty_entry_callable",
54
+                    ),
55
+                ],
56
+                id="not-callable",
57
+            ),
58
+            pytest.param(
59
+                [
60
+                    importlib.metadata.EntryPoint(
61
+                        name=data.faulty_entry_name_exists.key,
62
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
63
+                        value="tests.data: faulty_entry_name_exists",
64
+                    ),
65
+                ],
66
+                id="name-already-exists",
67
+            ),
68
+            pytest.param(
69
+                [
70
+                    importlib.metadata.EntryPoint(
71
+                        name=data.faulty_entry_alias_exists.key,
72
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
73
+                        value="tests.data: faulty_entry_alias_exists",
74
+                    ),
75
+                ],
76
+                id="alias-already-exists",
77
+            ),
78
+        ],
79
+    )
80
+    GOOD_ENTRY_POINTS = pytest.mark.parametrize(
81
+        "additional_entry_points",
82
+        [
83
+            pytest.param(
84
+                [
85
+                    importlib.metadata.EntryPoint(
86
+                        name=data.posix_entry.key,
87
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
88
+                        value="tests.data: posix_entry",
89
+                    ),
90
+                    importlib.metadata.EntryPoint(
91
+                        name=data.the_annoying_os_entry.key,
92
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
93
+                        value="tests.data: the_annoying_os_entry",
94
+                    ),
95
+                ],
96
+                id="existing-entries",
97
+            ),
98
+            pytest.param(
99
+                [
100
+                    importlib.metadata.EntryPoint(
101
+                        name=callables.provider_entry1.key,
102
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
103
+                        value="tests.data.callables: provider_entry1",
104
+                    ),
105
+                    importlib.metadata.EntryPoint(
106
+                        name=callables.provider_entry2.key,
107
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
108
+                        value="tests.data.callables: provider_entry2",
109
+                    ),
110
+                ],
111
+                id="new-entries",
112
+            ),
113
+        ],
114
+    )
115
+    STUBBED_AGENT_ADDRESSES = pytest.mark.parametrize(
116
+        ["address", "exception", "match"],
117
+        [
118
+            pytest.param(None, KeyError, "SSH_AUTH_SOCK", id="unset"),
119
+            pytest.param("stub-ssh-agent:", None, "", id="standard"),
120
+            pytest.param(
121
+                str(pathlib.Path("~").expanduser()),
122
+                FileNotFoundError,
123
+                os.strerror(errno.ENOENT),
124
+                id="invalid-url",
125
+            ),
126
+            pytest.param(
127
+                "stub-ssh-agent:EPROTONOSUPPORT",
128
+                OSError,
129
+                os.strerror(errno.EPROTONOSUPPORT),
130
+                id="protocol-not-supported",
131
+            ),
132
+            pytest.param(
133
+                "stub-ssh-agent:ABCDEFGHIJKLMNOPQRSTUVWXYZ",
134
+                OSError,
135
+                os.strerror(errno.EINVAL),
136
+                id="invalid-error-code",
137
+            ),
138
+        ],
139
+    )
140
+    EXISTING_REGISTRY_ENTRIES = pytest.mark.parametrize(
141
+        "existing", ["posix", "the_annoying_os"]
142
+    )
143
+    SSH_STRING_EXCEPTIONS = pytest.mark.parametrize(
144
+        ["input", "exc_type", "exc_pattern"],
145
+        [
146
+            pytest.param(
147
+                "some string", TypeError, "invalid payload type", id="str"
148
+            ),
149
+        ],
150
+    )
151
+    UINT32_EXCEPTIONS = pytest.mark.parametrize(
152
+        ["input", "exc_type", "exc_pattern"],
153
+        [
154
+            pytest.param(
155
+                10000000000000000,
156
+                OverflowError,
157
+                "int too big to convert",
158
+                id="10000000000000000",
159
+            ),
160
+            pytest.param(
161
+                -1,
162
+                OverflowError,
163
+                "can't convert negative int to unsigned",
164
+                id="-1",
165
+            ),
166
+        ],
167
+    )
168
+    SSH_UNSTRING_EXCEPTIONS = pytest.mark.parametrize(
169
+        ["input", "exc_type", "exc_pattern", "has_trailer", "parts"],
170
+        [
171
+            pytest.param(
172
+                b"ssh",
173
+                ValueError,
174
+                "malformed SSH byte string",
175
+                False,
176
+                None,
177
+                id="unencoded",
178
+            ),
179
+            pytest.param(
180
+                b"\x00\x00\x00\x08ssh-rsa",
181
+                ValueError,
182
+                "malformed SSH byte string",
183
+                False,
184
+                None,
185
+                id="truncated",
186
+            ),
187
+            pytest.param(
188
+                b"\x00\x00\x00\x04XXX trailing text",
189
+                ValueError,
190
+                "malformed SSH byte string",
191
+                True,
192
+                (b"XXX ", b"trailing text"),
193
+                id="trailing-data",
194
+            ),
195
+        ],
196
+    )
197
+    SSH_STRING_INPUT = pytest.mark.parametrize(
198
+        ["input", "expected"],
199
+        [
200
+            pytest.param(
201
+                b"ssh-rsa",
202
+                b"\x00\x00\x00\x07ssh-rsa",
203
+                id="ssh-rsa",
204
+            ),
205
+            pytest.param(
206
+                b"ssh-ed25519",
207
+                b"\x00\x00\x00\x0bssh-ed25519",
208
+                id="ssh-ed25519",
209
+            ),
210
+            pytest.param(
211
+                ssh_agent.SSHAgentClient.string(b"ssh-ed25519"),
212
+                b"\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519",
213
+                id="string(ssh-ed25519)",
214
+            ),
215
+        ],
216
+    )
217
+    SSH_UNSTRING_INPUT = pytest.mark.parametrize(
218
+        ["input", "expected"],
219
+        [
220
+            pytest.param(
221
+                b"\x00\x00\x00\x07ssh-rsa",
222
+                b"ssh-rsa",
223
+                id="ssh-rsa",
224
+            ),
225
+            pytest.param(
226
+                ssh_agent.SSHAgentClient.string(b"ssh-ed25519"),
227
+                b"ssh-ed25519",
228
+                id="ssh-ed25519",
229
+            ),
230
+        ],
231
+    )
232
+    UINT32_INPUT = pytest.mark.parametrize(
233
+        ["input", "expected"],
234
+        [
235
+            pytest.param(16777216, b"\x01\x00\x00\x00", id="16777216"),
236
+        ],
237
+    )
238
+    SIGN_ERROR_RESPONSES = pytest.mark.parametrize(
239
+        [
240
+            "key",
241
+            "check",
242
+            "response_code",
243
+            "response",
244
+            "exc_type",
245
+            "exc_pattern",
246
+        ],
247
+        [
248
+            pytest.param(
249
+                b"invalid-key",
250
+                True,
251
+                _types.SSH_AGENT.FAILURE,
252
+                b"",
253
+                KeyError,
254
+                "target SSH key not loaded into agent",
255
+                id="key-not-loaded",
256
+            ),
257
+            pytest.param(
258
+                data.SUPPORTED_KEYS["ed25519"].public_key_data,
259
+                True,
260
+                _types.SSH_AGENT.FAILURE,
261
+                b"",
262
+                ssh_agent.SSHAgentFailedError,
263
+                "failed to complete the request",
264
+                id="failed-to-complete",
265
+            ),
266
+        ],
267
+    )
268
+    SSH_KEY_SELECTION = pytest.mark.parametrize(
269
+        ["key", "single"],
270
+        [
271
+            (value.public_key_data, False)
272
+            for value in data.SUPPORTED_KEYS.values()
273
+        ]
274
+        + [(callables.list_keys_singleton()[0].key, True)],
275
+        ids=[*data.SUPPORTED_KEYS.keys(), "singleton"],
276
+    )
277
+    SH_EXPORT_LINES = pytest.mark.parametrize(
278
+        ["line", "env_name", "value"],
279
+        [
280
+            pytest.param(
281
+                "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK;",
282
+                "SSH_AUTH_SOCK",
283
+                "/tmp/pageant.user/pageant.27170",
284
+                id="value-export-semicolon-pageant",
285
+            ),
286
+            pytest.param(
287
+                "SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270; export SSH_AUTH_SOCK;",
288
+                "SSH_AUTH_SOCK",
289
+                "/tmp/ssh-3CSTC1W5M22A/agent.27270",
290
+                id="value-export-semicolon-openssh",
291
+            ),
292
+            pytest.param(
293
+                "SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170; export SSH_AUTH_SOCK",
294
+                "SSH_AUTH_SOCK",
295
+                "/tmp/pageant.user/pageant.27170",
296
+                id="value-export-pageant",
297
+            ),
298
+            pytest.param(
299
+                "export SSH_AUTH_SOCK=/tmp/ssh-3CSTC1W5M22A/agent.27270;",
300
+                "SSH_AUTH_SOCK",
301
+                "/tmp/ssh-3CSTC1W5M22A/agent.27270",
302
+                id="export-value-semicolon-openssh",
303
+            ),
304
+            pytest.param(
305
+                "export SSH_AUTH_SOCK=/tmp/pageant.user/pageant.27170",
306
+                "SSH_AUTH_SOCK",
307
+                "/tmp/pageant.user/pageant.27170",
308
+                id="export-value-pageant",
309
+            ),
310
+            pytest.param(
311
+                "SSH_AGENT_PID=27170; export SSH_AGENT_PID;",
312
+                "SSH_AGENT_PID",
313
+                "27170",
314
+                id="pid-export-semicolon",
315
+            ),
316
+            pytest.param(
317
+                "SSH_AGENT_PID=27170; export SSH_AGENT_PID",
318
+                "SSH_AGENT_PID",
319
+                "27170",
320
+                id="pid-export",
321
+            ),
322
+            pytest.param(
323
+                "export SSH_AGENT_PID=27170;",
324
+                "SSH_AGENT_PID",
325
+                "27170",
326
+                id="export-pid-semicolon",
327
+            ),
328
+            pytest.param(
329
+                "export SSH_AGENT_PID=27170",
330
+                "SSH_AGENT_PID",
331
+                "27170",
332
+                id="export-pid",
333
+            ),
334
+            pytest.param(
335
+                "export VARIABLE=value; export OTHER_VARIABLE=other_value;",
336
+                "VARIABLE",
337
+                None,
338
+                id="export-too-much",
339
+            ),
340
+            pytest.param(
341
+                "VARIABLE=value",
342
+                "VARIABLE",
343
+                None,
344
+                id="no-export",
345
+            ),
346
+        ],
347
+    )
348
+    INVALID_SSH_AGENT_MESSAGES = pytest.mark.parametrize(
349
+        "message",
350
+        [
351
+            pytest.param(b"\x00\x00\x00\x00", id="empty-message"),
352
+            pytest.param(b"\x00\x00\x00\x0f\x0d", id="truncated-message"),
353
+            pytest.param(
354
+                b"\x00\x00\x00\x06\x1b\x00\x00\x00\x01\xff",
355
+                id="invalid-extension-name",
356
+            ),
357
+            pytest.param(
358
+                b"\x00\x00\x00\x11\x0d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
359
+                id="sign-with-trailing-data",
360
+            ),
361
+        ],
362
+    )
363
+    UNSUPPORTED_SSH_AGENT_MESSAGES = pytest.mark.parametrize(
364
+        "message",
365
+        [
366
+            pytest.param(
367
+                ssh_agent.SSHAgentClient.string(
368
+                    b"".join([
369
+                        b"\x0d",
370
+                        ssh_agent.SSHAgentClient.string(
371
+                            data.ALL_KEYS["rsa"].public_key_data
372
+                        ),
373
+                        ssh_agent.SSHAgentClient.string(vault.Vault.UUID),
374
+                        b"\x00\x00\x00\x02",
375
+                    ])
376
+                ),
377
+                id="sign-with-flags",
378
+            ),
379
+            pytest.param(
380
+                ssh_agent.SSHAgentClient.string(
381
+                    b"".join([
382
+                        b"\x0d",
383
+                        ssh_agent.SSHAgentClient.string(
384
+                            data.ALL_KEYS["ed25519"].public_key_data
385
+                        ),
386
+                        b"\x00\x00\x00\x08\x00\x01\x02\x03\x04\x05\x06\x07",
387
+                        b"\x00\x00\x00\x00",
388
+                    ])
389
+                ),
390
+                id="sign-with-nonstandard-passphrase",
391
+            ),
392
+            pytest.param(
393
+                ssh_agent.SSHAgentClient.string(
394
+                    b"".join([
395
+                        b"\x0d",
396
+                        ssh_agent.SSHAgentClient.string(
397
+                            data.ALL_KEYS["dsa1024"].public_key_data
398
+                        ),
399
+                        ssh_agent.SSHAgentClient.string(vault.Vault.UUID),
400
+                        b"\x00\x00\x00\x00",
401
+                    ])
402
+                ),
403
+                id="sign-key-no-expected-signature",
404
+            ),
405
+            pytest.param(
406
+                ssh_agent.SSHAgentClient.string(
407
+                    b"".join([
408
+                        b"\x0d",
409
+                        b"\x00\x00\x00\x00",
410
+                        ssh_agent.SSHAgentClient.string(vault.Vault.UUID),
411
+                        b"\x00\x00\x00\x00",
412
+                    ])
413
+                ),
414
+                id="sign-key-unregistered-test-key",
415
+            ),
416
+        ],
417
+    )
418
+    PUBLIC_KEY_DATA = pytest.mark.parametrize(
419
+        "public_key_struct",
420
+        list(data.SUPPORTED_KEYS.values()),
421
+        ids=list(data.SUPPORTED_KEYS.keys()),
422
+    )
423
+    REQUEST_ERROR_RESPONSES = pytest.mark.parametrize(
424
+        ["request_code", "response_code", "exc_type", "exc_pattern"],
425
+        [
426
+            pytest.param(
427
+                _types.SSH_AGENTC.REQUEST_IDENTITIES,
428
+                _types.SSH_AGENT.SUCCESS,
429
+                ssh_agent.SSHAgentFailedError,
430
+                re.escape(
431
+                    f"[Code {_types.SSH_AGENT.IDENTITIES_ANSWER.value}]"
432
+                ),
433
+                id="REQUEST_IDENTITIES-expect-SUCCESS",
434
+            ),
435
+        ],
436
+    )
437
+    TRUNCATED_AGENT_RESPONSES = pytest.mark.parametrize(
438
+        "response",
439
+        [
440
+            b"\x00\x00",
441
+            b"\x00\x00\x00\x1f some bytes missing",
442
+        ],
443
+        ids=["in-header", "in-body"],
444
+    )
445
+    LIST_KEYS_ERROR_RESPONSES = pytest.mark.parametrize(
446
+        ["response_code", "response", "exc_type", "exc_pattern"],
447
+        [
448
+            pytest.param(
449
+                _types.SSH_AGENT.FAILURE,
450
+                b"",
451
+                ssh_agent.SSHAgentFailedError,
452
+                "failed to complete the request",
453
+                id="failed-to-complete",
454
+            ),
455
+            pytest.param(
456
+                _types.SSH_AGENT.IDENTITIES_ANSWER,
457
+                b"\x00\x00\x00\x01",
458
+                EOFError,
459
+                "truncated response",
460
+                id="truncated-response",
461
+            ),
462
+            pytest.param(
463
+                _types.SSH_AGENT.IDENTITIES_ANSWER,
464
+                b"\x00\x00\x00\x00abc",
465
+                ssh_agent.TrailingDataError,
466
+                "Overlong response",
467
+                id="overlong-response",
468
+            ),
469
+        ],
470
+    )
471
+    QUERY_EXTENSIONS_MALFORMED_RESPONSES = pytest.mark.parametrize(
472
+        "response_data",
473
+        [
474
+            pytest.param(b"\xde\xad\xbe\xef", id="truncated"),
475
+            pytest.param(
476
+                b"\x00\x00\x00\x0fwrong extension", id="wrong-extension"
477
+            ),
478
+            pytest.param(
479
+                b"\x00\x00\x00\x05query\xde\xad\xbe\xef", id="with-trailer"
480
+            ),
481
+            pytest.param(
482
+                b"\x00\x00\x00\x05query\x00\x00\x00\x04ext1\x00\x00",
483
+                id="with-extra-fields",
484
+            ),
485
+        ],
486
+    )
487
+    SUPPORTED_SSH_TEST_KEYS = pytest.mark.parametrize(
488
+        ["ssh_test_key_type", "ssh_test_key"],
489
+        list(data.SUPPORTED_KEYS.items()),
490
+        ids=data.SUPPORTED_KEYS.keys(),
491
+    )
492
+    UNSUITABLE_SSH_TEST_KEYS = pytest.mark.parametrize(
493
+        ["ssh_test_key_type", "ssh_test_key"],
494
+        list(data.UNSUITABLE_KEYS.items()),
495
+        ids=data.UNSUITABLE_KEYS.keys(),
496
+    )
497
+    RESOLVE_CHAINS = pytest.mark.parametrize(
498
+        ["terminal", "chain"],
499
+        [
500
+            pytest.param("callable", ["a"], id="callable-1"),
501
+            pytest.param("callable", ["a", "b", "c", "d"], id="callable-4"),
502
+            pytest.param("alias", ["e"], id="alias-5"),
503
+            pytest.param("alias", ["e", "f", "g", "h", "i"], id="alias-5"),
504
+            pytest.param("unimplemented", ["j"], id="unimplemented-1"),
505
+            pytest.param("unimplemented", ["j", "k"], id="unimplemented-2"),
506
+        ],
507
+    )
508
+
509
+
510
+class TestTestingMachineryStubbedSSHAgentSocket:
511
+    """Test the stubbed SSH agent socket for the `ssh_agent` module tests."""
512
+
513
+    def test_100a_query_extensions_base(self) -> None:
514
+        """The base agent implements no extensions."""
515
+        with contextlib.ExitStack() as stack:
516
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
517
+            monkeypatch.setenv(
518
+                "SSH_AUTH_SOCK",
519
+                machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
520
+            )
521
+            agent = stack.enter_context(
522
+                machinery.StubbedSSHAgentSocketWithAddress()
523
+            )
524
+            assert "query" not in agent.enabled_extensions
525
+            query_request = (
526
+                # SSH string header
527
+                b"\x00\x00\x00\x0a"
528
+                # request code: SSH_AGENTC_EXTENSION
529
+                b"\x1b"
530
+                # payload: SSH string "query"
531
+                b"\x00\x00\x00\x05query"
532
+            )
533
+            query_response = (
534
+                # SSH string header
535
+                b"\x00\x00\x00\x01"
536
+                # response code: SSH_AGENT_FAILURE
537
+                b"\x05"
538
+            )
539
+            agent.sendall(query_request)
540
+            assert agent.recv(1000) == query_response
541
+
542
+    def test_100b_query_extensions_extended(self) -> None:
543
+        """The extended agent implements a known list of extensions."""
544
+        with contextlib.ExitStack() as stack:
545
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
546
+            monkeypatch.setenv(
547
+                "SSH_AUTH_SOCK",
548
+                machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
549
+            )
550
+            agent = stack.enter_context(
551
+                machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA()
552
+            )
553
+            assert "query" in agent.enabled_extensions
554
+            query_request = (
555
+                # SSH string header
556
+                b"\x00\x00\x00\x0a"
557
+                # request code: SSH_AGENTC_EXTENSION
558
+                b"\x1b"
559
+                # payload: SSH string "query"
560
+                b"\x00\x00\x00\x05query"
561
+            )
562
+            query_response = (
563
+                # SSH string header
564
+                b"\x00\x00\x00\x40"
565
+                # response code: SSH_AGENT_EXTENSION_RESPONSE
566
+                b"\x1d"
567
+                # extension response: extension type ("query")
568
+                b"\x00\x00\x00\x05query"
569
+                # supported extension #1: query
570
+                b"\x00\x00\x00\x05query"
571
+                # supported extension #2:
572
+                # list-extended@putty.projects.tartarus.org
573
+                b"\x00\x00\x00\x29list-extended@putty.projects.tartarus.org"
574
+            )
575
+            agent.sendall(query_request)
576
+            assert agent.recv(1000) == query_response
577
+
578
+    def test_101_request_identities(self) -> None:
579
+        """The agent implements a known list of identities."""
580
+        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
581
+        with machinery.StubbedSSHAgentSocket() as agent:
582
+            query_request = (
583
+                # SSH string header
584
+                b"\x00\x00\x00\x01"
585
+                # request code: SSH_AGENTC_REQUEST_IDENTITIES
586
+                b"\x0b"
587
+            )
588
+            agent.sendall(query_request)
589
+            message_length = int.from_bytes(agent.recv(4), "big")
590
+            orig_message: bytes | bytearray = bytearray(
591
+                agent.recv(message_length)
592
+            )
593
+            assert (
594
+                _types.SSH_AGENT(orig_message[0])
595
+                == _types.SSH_AGENT.IDENTITIES_ANSWER
596
+            )
597
+            identity_count = int.from_bytes(orig_message[1:5], "big")
598
+            message = bytes(orig_message[5:])
599
+            for _ in range(identity_count):
600
+                key, message = unstring_prefix(message)
601
+                _comment, message = unstring_prefix(message)
602
+                assert key
603
+                assert key in {
604
+                    k.public_key_data for k in data.ALL_KEYS.values()
605
+                }
606
+            assert not message
607
+
608
+    @Parametrize.SUPPORTED_SSH_TEST_KEYS
609
+    def test_102_sign(
610
+        self,
611
+        ssh_test_key_type: str,
612
+        ssh_test_key: data.SSHTestKey,
613
+    ) -> None:
614
+        """The agent signs known key/message pairs."""
615
+        del ssh_test_key_type
616
+        spec = data.SSHTestKeyDeterministicSignatureClass.SPEC
617
+        assert ssh_test_key.expected_signatures[spec].signature is not None
618
+        string = ssh_agent.SSHAgentClient.string
619
+        query_request = string(
620
+            # request code: SSH_AGENTC_SIGN_REQUEST
621
+            b"\x0d"
622
+            # key: SSH string of the public key
623
+            + string(ssh_test_key.public_key_data)
624
+            # payload: SSH string of the vault UUID
625
+            + string(vault.Vault.UUID)
626
+            # signing flags (uint32, empty)
627
+            + b"\x00\x00\x00\x00"
628
+        )
629
+        query_response = string(
630
+            # response code: SSH_AGENT_SIGN_RESPONSE
631
+            b"\x0e"
632
+            # expected payload: the binary signature as recorded in the test key data structure
633
+            + string(ssh_test_key.expected_signatures[spec].signature)
634
+        )
635
+        with machinery.StubbedSSHAgentSocket() as agent:
636
+            agent.sendall(query_request)
637
+            assert agent.recv(1000) == query_response
638
+
639
+    def test_120_close_multiple(self) -> None:
640
+        """The agent can be closed repeatedly."""
641
+        with machinery.StubbedSSHAgentSocket() as agent:
642
+            pass
643
+        with machinery.StubbedSSHAgentSocket() as agent:
644
+            pass
645
+        del agent
646
+
647
+    def test_121_closed_agents_cannot_be_interacted_with(self) -> None:
648
+        """The agent can be closed repeatedly."""
649
+        with machinery.StubbedSSHAgentSocket() as agent:
650
+            pass
651
+        query_request = (
652
+            # SSH string header
653
+            b"\x00\x00\x00\x0a"
654
+            # request code: SSH_AGENTC_EXTENSION
655
+            b"\x1b"
656
+            # payload: SSH string "query"
657
+            b"\x00\x00\x00\x05query"
658
+        )
659
+        query_response = b""
660
+        with pytest.raises(
661
+            ValueError,
662
+            match=re.escape(machinery.StubbedSSHAgentSocket._SOCKET_IS_CLOSED),
663
+        ):
664
+            agent.sendall(query_request)
665
+        assert agent.recv(100) == query_response
666
+
667
+    def test_122_no_recv_without_sendall(self) -> None:
668
+        """The agent requires a message before sending a response."""
669
+        with machinery.StubbedSSHAgentSocket() as agent:  # noqa: SIM117
670
+            with pytest.raises(
671
+                AssertionError,
672
+                match=re.escape(
673
+                    machinery.StubbedSSHAgentSocket._PROTOCOL_VIOLATION
674
+                ),
675
+            ):
676
+                agent.recv(100)
677
+
678
+    @Parametrize.INVALID_SSH_AGENT_MESSAGES
679
+    def test_123_invalid_ssh_agent_messages(
680
+        self,
681
+        message: Buffer,
682
+    ) -> None:
683
+        """The agent responds with errors on invalid messages."""
684
+        query_response = (
685
+            # SSH string header
686
+            b"\x00\x00\x00\x01"
687
+            # response code: SSH_AGENT_FAILURE
688
+            b"\x05"
689
+        )
690
+        with machinery.StubbedSSHAgentSocket() as agent:
691
+            agent.sendall(message)
692
+            assert agent.recv(100) == query_response
693
+
694
+    @Parametrize.UNSUPPORTED_SSH_AGENT_MESSAGES
695
+    def test_124_unsupported_ssh_agent_messages(
696
+        self,
697
+        message: Buffer,
698
+    ) -> None:
699
+        """The agent responds with errors on unsupported messages."""
700
+        query_response = (
701
+            # SSH string header
702
+            b"\x00\x00\x00\x01"
703
+            # response code: SSH_AGENT_FAILURE
704
+            b"\x05"
705
+        )
706
+        with machinery.StubbedSSHAgentSocket() as agent:
707
+            agent.sendall(message)
708
+            assert agent.recv(100) == query_response
709
+
710
+    @Parametrize.STUBBED_AGENT_ADDRESSES
711
+    def test_125_addresses(
712
+        self,
713
+        address: str | None,
714
+        exception: type[Exception] | None,
715
+        match: str,
716
+    ) -> None:
717
+        """The agent accepts addresses."""
718
+        with contextlib.ExitStack() as stack:
719
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
720
+            if address:
721
+                monkeypatch.setenv("SSH_AUTH_SOCK", address)
722
+            else:
723
+                monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
724
+            if exception:
725
+                stack.enter_context(
726
+                    pytest.raises(exception, match=re.escape(match))
727
+                )
728
+            machinery.StubbedSSHAgentSocketWithAddress()
729
+
730
+
731
+class TestStaticFunctionality:
732
+    """Test the static functionality of the `ssh_agent` module."""
733
+
734
+    @staticmethod
735
+    def as_ssh_string(bytestring: bytes) -> bytes:
736
+        """Return an encoded SSH string from a bytestring.
737
+
738
+        This is a helper function for hypothesis data generation.
739
+
740
+        """
741
+        return int.to_bytes(len(bytestring), 4, "big") + bytestring
742
+
743
+    @staticmethod
744
+    def canonicalize1(data: bytes) -> bytes:
745
+        """Return an encoded SSH string from a bytestring.
746
+
747
+        This is a helper function for hypothesis testing.
748
+
749
+        References:
750
+
751
+          * [David R. MacIver: Another invariant to test for
752
+            encoders][DECODE_ENCODE]
753
+
754
+        [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/
755
+
756
+        """
757
+        return ssh_agent.SSHAgentClient.string(
758
+            ssh_agent.SSHAgentClient.unstring(data)
759
+        )
760
+
761
+    @staticmethod
762
+    def canonicalize2(data: bytes) -> bytes:
763
+        """Return an encoded SSH string from a bytestring.
764
+
765
+        This is a helper function for hypothesis testing.
766
+
767
+        References:
768
+
769
+          * [David R. MacIver: Another invariant to test for
770
+            encoders][DECODE_ENCODE]
771
+
772
+        [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/
773
+
774
+        """
775
+        unstringed, trailer = ssh_agent.SSHAgentClient.unstring_prefix(data)
776
+        assert not trailer
777
+        return ssh_agent.SSHAgentClient.string(unstringed)
778
+
779
+    # TODO(the-13th-letter): Re-evaluate if this check is worth keeping.
780
+    # It cannot provide true tamper-resistence, but probably appears to.
781
+    @Parametrize.PUBLIC_KEY_DATA
782
+    def test_100_key_decoding(
783
+        self,
784
+        public_key_struct: data.SSHTestKey,
785
+    ) -> None:
786
+        """The [`tests.ALL_KEYS`][] public key data looks sane."""
787
+        keydata = base64.b64decode(
788
+            public_key_struct.public_key.split(None, 2)[1]
789
+        )
790
+        assert keydata == public_key_struct.public_key_data, (
791
+            "recorded public key data doesn't match"
792
+        )
793
+
794
+    @Parametrize.SH_EXPORT_LINES
795
+    def test_190_sh_export_line_parsing(
796
+        self, line: str, env_name: str, value: str | None
797
+    ) -> None:
798
+        """[`tests.parse_sh_export_line`][] works."""
799
+        if value is not None:
800
+            assert (
801
+                callables.parse_sh_export_line(line, env_name=env_name)
802
+                == value
803
+            )
804
+        else:
805
+            with pytest.raises(ValueError, match="Cannot parse sh line:"):
806
+                callables.parse_sh_export_line(line, env_name=env_name)
807
+
808
+    def test_200_constructor_posix_no_ssh_auth_sock(
809
+        self,
810
+        skip_if_no_af_unix_support: None,
811
+    ) -> None:
812
+        """Abort if the running agent cannot be located on POSIX."""
813
+        del skip_if_no_af_unix_support
814
+        posix_handler = socketprovider.SocketProvider.resolve("posix")
815
+        with pytest.MonkeyPatch.context() as monkeypatch:
816
+            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
817
+            with pytest.raises(
818
+                KeyError, match="SSH_AUTH_SOCK environment variable"
819
+            ):
820
+                posix_handler()
821
+
822
+    @Parametrize.UINT32_INPUT
823
+    def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None:
824
+        """`uint32` encoding works."""
825
+        uint32 = ssh_agent.SSHAgentClient.uint32
826
+        assert uint32(input) == expected
827
+
828
+    @hypothesis.given(strategies.integers(min_value=0, max_value=0xFFFFFFFF))
829
+    @hypothesis.example(0xDEADBEEF).via("manual, pre-hypothesis example")
830
+    def test_210a_uint32_from_number(self, num: int) -> None:
831
+        """`uint32` encoding works, starting from numbers."""
832
+        uint32 = ssh_agent.SSHAgentClient.uint32
833
+        assert int.from_bytes(uint32(num), "big", signed=False) == num
834
+
835
+    @hypothesis.given(strategies.binary(min_size=4, max_size=4))
836
+    @hypothesis.example(b"\xde\xad\xbe\xef").via(
837
+        "manual, pre-hypothesis example"
838
+    )
839
+    def test_210b_uint32_from_bytestring(self, bytestring: bytes) -> None:
840
+        """`uint32` encoding works, starting from length four byte strings."""
841
+        uint32 = ssh_agent.SSHAgentClient.uint32
842
+        assert (
843
+            uint32(int.from_bytes(bytestring, "big", signed=False))
844
+            == bytestring
845
+        )
846
+
847
+    @Parametrize.SSH_STRING_INPUT
848
+    def test_211_string(
849
+        self, input: bytes | bytearray, expected: bytes | bytearray
850
+    ) -> None:
851
+        """SSH string encoding works."""
852
+        string = ssh_agent.SSHAgentClient.string
853
+        assert bytes(string(input)) == expected
854
+
855
+    @hypothesis.given(strategies.binary(max_size=0x0001FFFF))
856
+    @hypothesis.example(b"DEADBEEF" * 10000).via(
857
+        "manual, pre-hypothesis example with highest order bit set"
858
+    )
859
+    def test_211a_string_from_bytestring(self, bytestring: bytes) -> None:
860
+        """SSH string encoding works, starting from a byte string."""
861
+        res = ssh_agent.SSHAgentClient.string(bytestring)
862
+        assert res.startswith((b"\x00\x00", b"\x00\x01"))
863
+        assert int.from_bytes(res[:4], "big", signed=False) == len(bytestring)
864
+        assert res[4:] == bytestring
865
+
866
+    @Parametrize.SSH_UNSTRING_INPUT
867
+    def test_212_unstring(
868
+        self, input: bytes | bytearray, expected: bytes | bytearray
869
+    ) -> None:
870
+        """SSH string decoding works."""
871
+        unstring = ssh_agent.SSHAgentClient.unstring
872
+        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
873
+        assert bytes(unstring(input)) == expected
874
+        assert tuple(bytes(x) for x in unstring_prefix(input)) == (
875
+            expected,
876
+            b"",
877
+        )
878
+
879
+    @hypothesis.given(strategies.binary(max_size=0x00FFFFFF))
880
+    @hypothesis.example(b"\x00\x00\x00\x07ssh-rsa").via(
881
+        "manual, pre-hypothesis example to attempt to detect double-decoding"
882
+    )
883
+    @hypothesis.example(b"\x00\x00\x00\x01").via(
884
+        "detect no-op encoding via ill-formed SSH string"
885
+    )
886
+    def test_212a_unstring_of_string_of_data(self, bytestring: bytes) -> None:
887
+        """SSH string decoding of encoded SSH strings works.
888
+
889
+        References:
890
+
891
+          * [David R. MacIver: The Encode/Decode invariant][ENCODE_DECODE]
892
+
893
+        [ENCODE_DECODE]: https://hypothesis.works/articles/encode-decode-invariant/
894
+
895
+        """
896
+        string = ssh_agent.SSHAgentClient.string
897
+        unstring = ssh_agent.SSHAgentClient.unstring
898
+        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
899
+        encoded = string(bytestring)
900
+        assert unstring(encoded) == bytestring
901
+        assert unstring_prefix(encoded) == (bytestring, b"")
902
+        trailing_data = b"  trailing data"
903
+        encoded2 = string(bytestring) + trailing_data
904
+        assert unstring_prefix(encoded2) == (bytestring, trailing_data)
905
+
906
+    @hypothesis.given(
907
+        strategies.binary(max_size=0x00FFFFFF).map(
908
+            # Scoping issues, and the fact that staticmethod objects
909
+            # (before class finalization) are not callable, necessitate
910
+            # wrapping this staticmethod call in a lambda.
911
+            lambda x: TestStaticFunctionality.as_ssh_string(x)  # noqa: PLW0108
912
+        ),
913
+    )
914
+    def test_212b_string_of_unstring_of_data(self, encoded: bytes) -> None:
915
+        """SSH string decoding of encoded SSH strings works.
916
+
917
+        References:
918
+
919
+          * [David R. MacIver: Another invariant to test for
920
+            encoders][DECODE_ENCODE]
921
+
922
+        [DECODE_ENCODE]: https://hypothesis.works/articles/canonical-serialization/
923
+
924
+        """
925
+        canonical_functions = [self.canonicalize1, self.canonicalize2]
926
+        for canon1 in canonical_functions:
927
+            for canon2 in canonical_functions:
928
+                assert canon1(encoded) == canon2(encoded)
929
+                assert canon1(canon2(encoded)) == canon1(encoded)
930
+
931
+    def test_220_registry_resolve(
932
+        self,
933
+    ) -> None:
934
+        """Resolving entries in the socket provider registry works."""
935
+        registry = socketprovider.SocketProvider.registry
936
+        resolve = socketprovider.SocketProvider.resolve
937
+        lookup = socketprovider.SocketProvider.lookup
938
+        with pytest.MonkeyPatch.context() as monkeypatch:
939
+            monkeypatch.setitem(registry, "stub_agent", None)
940
+            assert callable(lookup("native"))
941
+            assert callable(resolve("native"))
942
+            assert lookup("stub_agent") is None
943
+            with pytest.raises(NotImplementedError):
944
+                resolve("stub_agent")
945
+
946
+    @Parametrize.RESOLVE_CHAINS
947
+    def test_221_registry_resolve_chains(
948
+        self,
949
+        terminal: Literal["unimplemented", "alias", "callable"],
950
+        chain: list[str],
951
+    ) -> None:
952
+        """Resolving a chain of providers works."""
953
+        registry = socketprovider.SocketProvider.registry
954
+        resolve = socketprovider.SocketProvider.resolve
955
+        lookup = socketprovider.SocketProvider.lookup
956
+        try:
957
+            implementation = resolve("native")
958
+        except NotImplementedError:  # pragma: no cover
959
+            pytest.fail("Native SSH agent socket provider is unavailable?!")
960
+        # TODO(the-13th-letter): Rewrite using structural pattern matching.
961
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
962
+        target = (
963
+            None
964
+            if terminal == "unimplemented"
965
+            else "native"
966
+            if terminal == "alias"
967
+            else implementation
968
+        )
969
+        with pytest.MonkeyPatch.context() as monkeypatch:
970
+            for link in chain:
971
+                monkeypatch.setitem(registry, link, target)
972
+                target = link
973
+            for link in chain:
974
+                assert lookup(link) == (
975
+                    implementation if terminal != "unimplemented" else None
976
+                )
977
+                if terminal == "unimplemented":
978
+                    with pytest.raises(NotImplementedError):
979
+                        resolve(link)
980
+                else:
981
+                    assert resolve(link) == implementation
982
+
983
+    @hypothesis.given(
984
+        terminal=strategies.sampled_from([
985
+            "unimplemented",
986
+            "alias",
987
+            "callable",
988
+        ]),
989
+        chain=strategies.lists(
990
+            strategies.sampled_from([
991
+                "c1",
992
+                "c2",
993
+                "c3",
994
+                "c4",
995
+                "c5",
996
+                "c6",
997
+                "c7",
998
+                "c8",
999
+                "c9",
1000
+                "c10",
1001
+            ]),
1002
+            min_size=1,
1003
+            unique=True,
1004
+        ),
1005
+    )
1006
+    def test_221a_registry_resolve_chains(
1007
+        self,
1008
+        terminal: Literal["unimplemented", "alias", "callable"],
1009
+        chain: list[str],
1010
+    ) -> None:
1011
+        """Resolving a chain of providers works."""
1012
+        registry = socketprovider.SocketProvider.registry
1013
+        resolve = socketprovider.SocketProvider.resolve
1014
+        lookup = socketprovider.SocketProvider.lookup
1015
+        try:
1016
+            implementation = resolve("native")
1017
+        except NotImplementedError:  # pragma: no cover
1018
+            hypothesis.note(f"{registry = }")
1019
+            pytest.fail("Native SSH agent socket provider is unavailable?!")
1020
+        # TODO(the-13th-letter): Rewrite using structural pattern matching.
1021
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1022
+        target = (
1023
+            None
1024
+            if terminal == "unimplemented"
1025
+            else "native"
1026
+            if terminal == "alias"
1027
+            else implementation
1028
+        )
1029
+        with pytest.MonkeyPatch.context() as monkeypatch:
1030
+            for link in chain:
1031
+                monkeypatch.setitem(registry, link, target)
1032
+                target = link
1033
+            for link in chain:
1034
+                assert lookup(link) == (
1035
+                    implementation if terminal != "unimplemented" else None
1036
+                )
1037
+                if terminal == "unimplemented":
1038
+                    with pytest.raises(NotImplementedError):
1039
+                        resolve(link)
1040
+                else:
1041
+                    assert resolve(link) == implementation
1042
+
1043
+    @Parametrize.GOOD_ENTRY_POINTS
1044
+    def test_230_find_all_socket_providers(
1045
+        self,
1046
+        additional_entry_points: list[importlib.metadata.EntryPoint],
1047
+    ) -> None:
1048
+        """Finding all SSH agent socket providers works."""
1049
+        resolve = socketprovider.SocketProvider.resolve
1050
+        old_registry = socketprovider.SocketProvider.registry
1051
+        with pytest_machinery.faked_entry_point_list(
1052
+            additional_entry_points, remove_conflicting_entries=False
1053
+        ) as names:
1054
+            socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
1055
+            for name in names:
1056
+                assert name in socketprovider.SocketProvider.registry
1057
+                assert resolve(name) in {
1058
+                    callables.provider_entry_provider,
1059
+                    *old_registry.values(),
1060
+                }
1061
+
1062
+    @Parametrize.BAD_ENTRY_POINTS
1063
+    def test_231_find_all_socket_providers_errors(
1064
+        self,
1065
+        additional_entry_points: list[importlib.metadata.EntryPoint],
1066
+    ) -> None:
1067
+        """Finding faulty SSH agent socket providers raises errors."""
1068
+        with contextlib.ExitStack() as stack:
1069
+            stack.enter_context(
1070
+                pytest_machinery.faked_entry_point_list(
1071
+                    additional_entry_points, remove_conflicting_entries=False
1072
+                )
1073
+            )
1074
+            stack.enter_context(pytest.raises(AssertionError))
1075
+            socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
1076
+
1077
+    @Parametrize.UINT32_EXCEPTIONS
1078
+    def test_310_uint32_exceptions(
1079
+        self, input: int, exc_type: type[Exception], exc_pattern: str
1080
+    ) -> None:
1081
+        """`uint32` encoding fails for out-of-bound values."""
1082
+        uint32 = ssh_agent.SSHAgentClient.uint32
1083
+        with pytest.raises(exc_type, match=exc_pattern):
1084
+            uint32(input)
1085
+
1086
+    @Parametrize.SSH_STRING_EXCEPTIONS
1087
+    def test_311_string_exceptions(
1088
+        self, input: Any, exc_type: type[Exception], exc_pattern: str
1089
+    ) -> None:
1090
+        """SSH string encoding fails for non-strings."""
1091
+        string = ssh_agent.SSHAgentClient.string
1092
+        with pytest.raises(exc_type, match=exc_pattern):
1093
+            string(input)
1094
+
1095
+    @Parametrize.SSH_UNSTRING_EXCEPTIONS
1096
+    def test_312_unstring_exceptions(
1097
+        self,
1098
+        input: bytes | bytearray,
1099
+        exc_type: type[Exception],
1100
+        exc_pattern: str,
1101
+        has_trailer: bool,
1102
+        parts: tuple[bytes | bytearray, bytes | bytearray] | None,
1103
+    ) -> None:
1104
+        """SSH string decoding fails for invalid values."""
1105
+        unstring = ssh_agent.SSHAgentClient.unstring
1106
+        unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
1107
+        with pytest.raises(exc_type, match=exc_pattern):
1108
+            unstring(input)
1109
+        if has_trailer:
1110
+            assert tuple(bytes(x) for x in unstring_prefix(input)) == parts
1111
+        else:
1112
+            with pytest.raises(exc_type, match=exc_pattern):
1113
+                unstring_prefix(input)
1114
+
1115
+    def test_320_registry_already_registered(
1116
+        self,
1117
+    ) -> None:
1118
+        """The registry forbids overwriting entries."""
1119
+        registry = socketprovider.SocketProvider.registry.copy()
1120
+        resolve = socketprovider.SocketProvider.resolve
1121
+        register = socketprovider.SocketProvider.register
1122
+        the_annoying_os = resolve("the_annoying_os")
1123
+        posix = resolve("posix")
1124
+        with pytest.MonkeyPatch.context() as monkeypatch:
1125
+            monkeypatch.setattr(
1126
+                socketprovider.SocketProvider, "registry", registry
1127
+            )
1128
+            register("posix")(posix)
1129
+            register("the_annoying_os")(the_annoying_os)
1130
+            with pytest.raises(ValueError, match="already registered"):
1131
+                register("posix")(the_annoying_os)
1132
+            with pytest.raises(ValueError, match="already registered"):
1133
+                register("the_annoying_os")(posix)
1134
+            with pytest.raises(ValueError, match="already registered"):
1135
+                register("posix", "the_annoying_os_named_pipe")(posix)
1136
+            with pytest.raises(ValueError, match="already registered"):
1137
+                register("the_annoying_os", "unix_domain")(the_annoying_os)
1138
+
1139
+    def test_321_registry_resolve_non_existant_entries(
1140
+        self,
1141
+    ) -> None:
1142
+        """Resolving a non-existant entry fails."""
1143
+        new_registry = {
1144
+            "posix": socketprovider.SocketProvider.registry["posix"],
1145
+            "the_annoying_os": socketprovider.SocketProvider.registry[
1146
+                "the_annoying_os"
1147
+            ],
1148
+        }
1149
+        with pytest.MonkeyPatch.context() as monkeypatch:
1150
+            monkeypatch.setattr(
1151
+                socketprovider.SocketProvider, "registry", new_registry
1152
+            )
1153
+            with pytest.raises(socketprovider.NoSuchProviderError):
1154
+                socketprovider.SocketProvider.resolve("native")
1155
+
1156
+    def test_322_registry_register_new_entry(
1157
+        self,
1158
+    ) -> None:
1159
+        """Registering new entries works."""
1160
+
1161
+        def socket_provider() -> _types.SSHAgentSocket:
1162
+            raise AssertionError
1163
+
1164
+        names = ["spam", "ham", "eggs", "parrot"]
1165
+        new_registry = {
1166
+            "posix": socketprovider.SocketProvider.registry["posix"],
1167
+            "the_annoying_os": socketprovider.SocketProvider.registry[
1168
+                "the_annoying_os"
1169
+            ],
1170
+        }
1171
+        with pytest.MonkeyPatch.context() as monkeypatch:
1172
+            monkeypatch.setattr(
1173
+                socketprovider.SocketProvider, "registry", new_registry
1174
+            )
1175
+            assert not any(
1176
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1177
+            )
1178
+            assert (
1179
+                socketprovider.SocketProvider.register(*names)(socket_provider)
1180
+                is socket_provider
1181
+            )
1182
+            assert all(
1183
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1184
+            )
1185
+            assert all([
1186
+                socketprovider.SocketProvider.resolve(n) is socket_provider
1187
+                for n in names
1188
+            ])
1189
+
1190
+    @Parametrize.EXISTING_REGISTRY_ENTRIES
1191
+    def test_323_registry_register_old_entry(
1192
+        self,
1193
+        existing: str,
1194
+    ) -> None:
1195
+        """Registering old entries works."""
1196
+
1197
+        provider = socketprovider.SocketProvider.resolve(existing)
1198
+        new_registry = {
1199
+            "posix": socketprovider.SocketProvider.registry["posix"],
1200
+            "the_annoying_os": socketprovider.SocketProvider.registry[
1201
+                "the_annoying_os"
1202
+            ],
1203
+            "unix_domain": "posix",
1204
+            "the_annoying_os_named_pipe": "the_annoying_os",
1205
+        }
1206
+        names = [
1207
+            k
1208
+            for k, v in socketprovider.SocketProvider.registry.items()
1209
+            if v == existing
1210
+        ]
1211
+        with pytest.MonkeyPatch.context() as monkeypatch:
1212
+            monkeypatch.setattr(
1213
+                socketprovider.SocketProvider, "registry", new_registry
1214
+            )
1215
+            assert not all(
1216
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1217
+            )
1218
+            assert (
1219
+                socketprovider.SocketProvider.register(existing, *names)(
1220
+                    provider
1221
+                )
1222
+                is provider
1223
+            )
1224
+            assert all(
1225
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1226
+            )
1227
+            assert all([
1228
+                socketprovider.SocketProvider.resolve(n) is provider
1229
+                for n in [existing, *names]
1230
+            ])
1231
+
1232
+
1233
+class TestAgentInteraction:
1234
+    """Test actually talking to the SSH agent."""
1235
+
1236
+    @Parametrize.SUPPORTED_SSH_TEST_KEYS
1237
+    def test_200_sign_data_via_agent(
1238
+        self,
1239
+        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1240
+        ssh_test_key_type: str,
1241
+        ssh_test_key: data.SSHTestKey,
1242
+    ) -> None:
1243
+        """Signing data with specific SSH keys works.
1244
+
1245
+        Single tests may abort early (skip) if the indicated key is not
1246
+        loaded in the agent.  Presumably this means the key type is
1247
+        unsupported.
1248
+
1249
+        """
1250
+        client = ssh_agent_client_with_test_keys_loaded
1251
+        key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
1252
+        public_key_data = ssh_test_key.public_key_data
1253
+        assert (
1254
+            data.SSHTestKeyDeterministicSignatureClass.SPEC
1255
+            in ssh_test_key.expected_signatures
1256
+        )
1257
+        sig = ssh_test_key.expected_signatures[
1258
+            data.SSHTestKeyDeterministicSignatureClass.SPEC
1259
+        ]
1260
+        expected_signature = sig.signature
1261
+        derived_passphrase = sig.derived_passphrase
1262
+        if public_key_data not in key_comment_pairs:  # pragma: no cover
1263
+            pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded")
1264
+        signature = bytes(
1265
+            client.sign(payload=vault.Vault.UUID, key=public_key_data)
1266
+        )
1267
+        assert signature == expected_signature, (
1268
+            f"SSH signature mismatch ({ssh_test_key_type})"
1269
+        )
1270
+        signature2 = bytes(
1271
+            client.sign(payload=vault.Vault.UUID, key=public_key_data)
1272
+        )
1273
+        assert signature2 == expected_signature, (
1274
+            f"SSH signature mismatch ({ssh_test_key_type})"
1275
+        )
1276
+        assert (
1277
+            vault.Vault.phrase_from_key(public_key_data, conn=client)
1278
+            == derived_passphrase
1279
+        ), f"SSH signature mismatch ({ssh_test_key_type})"
1280
+
1281
+    @Parametrize.UNSUITABLE_SSH_TEST_KEYS
1282
+    def test_201_sign_data_via_agent_unsupported(
1283
+        self,
1284
+        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1285
+        ssh_test_key_type: str,
1286
+        ssh_test_key: data.SSHTestKey,
1287
+    ) -> None:
1288
+        """Using an unsuitable key with [`vault.Vault`][] fails.
1289
+
1290
+        Single tests may abort early (skip) if the indicated key is not
1291
+        loaded in the agent.  Presumably this means the key type is
1292
+        unsupported.  Single tests may also abort early if the agent
1293
+        ensures that the generally unsuitable key is actually suitable
1294
+        under this agent.
1295
+
1296
+        """
1297
+        client = ssh_agent_client_with_test_keys_loaded
1298
+        key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
1299
+        public_key_data = ssh_test_key.public_key_data
1300
+        if public_key_data not in key_comment_pairs:  # pragma: no cover
1301
+            pytest.skip(f"prerequisite {ssh_test_key_type} SSH key not loaded")
1302
+        assert not vault.Vault.is_suitable_ssh_key(
1303
+            public_key_data, client=None
1304
+        ), f"Expected {ssh_test_key_type} key to be unsuitable in general"
1305
+        if vault.Vault.is_suitable_ssh_key(public_key_data, client=client):
1306
+            pytest.skip(
1307
+                f"agent automatically ensures {ssh_test_key_type} key is suitable"
1308
+            )
1309
+        with pytest.raises(ValueError, match="unsuitable SSH key"):
1310
+            vault.Vault.phrase_from_key(public_key_data, conn=client)
1311
+
1312
+    @Parametrize.SSH_KEY_SELECTION
1313
+    def test_210_ssh_key_selector(
1314
+        self,
1315
+        monkeypatch: pytest.MonkeyPatch,
1316
+        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1317
+        key: bytes,
1318
+        single: bool,
1319
+    ) -> None:
1320
+        """The key selector presents exactly the suitable keys.
1321
+
1322
+        "Suitable" here means suitability for this SSH agent
1323
+        specifically.
1324
+
1325
+        """
1326
+        client = ssh_agent_client_with_test_keys_loaded
1327
+
1328
+        def key_is_suitable(key: bytes) -> bool:
1329
+            """Stub out [`vault.Vault.key_is_suitable`][]."""
1330
+            always = {v.public_key_data for v in data.SUPPORTED_KEYS.values()}
1331
+            dsa = {
1332
+                v.public_key_data
1333
+                for k, v in data.UNSUITABLE_KEYS.items()
1334
+                if k.startswith(("dsa", "ecdsa"))
1335
+            }
1336
+            return key in always or (
1337
+                client.has_deterministic_dsa_signatures() and key in dsa
1338
+            )
1339
+
1340
+        # TODO(the-13th-letter): Handle the unlikely(?) case that only
1341
+        # one test key is loaded, but `single` is False.  Rename the
1342
+        # `index` variable to `input`, store the `input` in there, and
1343
+        # make the definition of `text` in the else block dependent on
1344
+        # `n` being singular or non-singular.
1345
+        if single:
1346
+            monkeypatch.setattr(
1347
+                ssh_agent.SSHAgentClient,
1348
+                "list_keys",
1349
+                callables.list_keys_singleton,
1350
+            )
1351
+            keys = [
1352
+                pair.key
1353
+                for pair in callables.list_keys_singleton()
1354
+                if key_is_suitable(pair.key)
1355
+            ]
1356
+            index = "1"
1357
+            text = "Use this key? yes\n"
1358
+        else:
1359
+            monkeypatch.setattr(
1360
+                ssh_agent.SSHAgentClient,
1361
+                "list_keys",
1362
+                callables.list_keys,
1363
+            )
1364
+            keys = [
1365
+                pair.key
1366
+                for pair in callables.list_keys()
1367
+                if key_is_suitable(pair.key)
1368
+            ]
1369
+            index = str(1 + keys.index(key))
1370
+            n = len(keys)
1371
+            text = f"Your selection? (1-{n}, leave empty to abort): {index}\n"
1372
+        b64_key = base64.standard_b64encode(key).decode("ASCII")
1373
+
1374
+        @click.command()
1375
+        def driver() -> None:
1376
+            """Call [`cli_helpers.select_ssh_key`][] directly, as a command."""
1377
+            key = cli_helpers.select_ssh_key(client)
1378
+            click.echo(base64.standard_b64encode(key).decode("ASCII"))
1379
+
1380
+        # TODO(the-13th-letter): (Continued from above.)  Update input
1381
+        # data to use `index`/`input` directly and unconditionally.
1382
+        runner = machinery.CliRunner(mix_stderr=True)
1383
+        result = runner.invoke(
1384
+            driver,
1385
+            [],
1386
+            input=("yes\n" if single else f"{index}\n"),
1387
+            catch_exceptions=True,
1388
+        )
1389
+        for snippet in ("Suitable SSH keys:\n", text, f"\n{b64_key}\n"):
1390
+            assert result.clean_exit(output=snippet), "expected clean exit"
1391
+
1392
+    def test_300_constructor_bad_running_agent(
1393
+        self,
1394
+        running_ssh_agent: data.RunningSSHAgentInfo,
1395
+    ) -> None:
1396
+        """Fail if the agent address is invalid."""
1397
+        with pytest.MonkeyPatch.context() as monkeypatch:
1398
+            new_socket_name = (
1399
+                running_ssh_agent.socket + "~"
1400
+                if isinstance(running_ssh_agent.socket, str)
1401
+                else "<invalid//address>"
1402
+            )
1403
+            monkeypatch.setenv("SSH_AUTH_SOCK", new_socket_name)
1404
+            with pytest.raises(OSError):  # noqa: PT011
1405
+                ssh_agent.SSHAgentClient()
1406
+
1407
+    def test_301_constructor_no_af_unix_support(self) -> None:
1408
+        """Fail without [`socket.AF_UNIX`][] support."""
1409
+        assert "posix" in socketprovider.SocketProvider.registry
1410
+        with pytest.MonkeyPatch.context() as monkeypatch:
1411
+            monkeypatch.setenv("SSH_AUTH_SOCK", "the value doesn't matter")
1412
+            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
1413
+            with pytest.raises(
1414
+                NotImplementedError,
1415
+                match="UNIX domain sockets",
1416
+            ):
1417
+                ssh_agent.SSHAgentClient(socket="posix")
1418
+
1419
+    def test_302_no_ssh_agent_socket_provider_available(
1420
+        self,
1421
+    ) -> None:
1422
+        """Fail if no SSH agent socket provider is available."""
1423
+        with pytest.MonkeyPatch.context() as monkeypatch:
1424
+            monkeypatch.setitem(
1425
+                socketprovider.SocketProvider.registry, "stub_agent", None
1426
+            )
1427
+            with pytest.raises(ExceptionGroup) as excinfo:
1428
+                ssh_agent.SSHAgentClient(
1429
+                    socket=["stub_agent", "stub_agent", "stub_agent"]
1430
+                )
1431
+            assert all([
1432
+                isinstance(e, NotImplementedError)
1433
+                for e in excinfo.value.exceptions
1434
+            ])
1435
+
1436
+    def test_303_explicit_socket(
1437
+        self,
1438
+        spawn_ssh_agent: data.SpawnedSSHAgentInfo,
1439
+    ) -> None:
1440
+        conn = spawn_ssh_agent.client._connection
1441
+        ssh_agent.SSHAgentClient(socket=conn)
1442
+
1443
+    @Parametrize.TRUNCATED_AGENT_RESPONSES
1444
+    def test_310_truncated_server_response(
1445
+        self,
1446
+        running_ssh_agent: data.RunningSSHAgentInfo,
1447
+        response: bytes,
1448
+    ) -> None:
1449
+        """Fail on truncated responses from the SSH agent."""
1450
+        del running_ssh_agent
1451
+        client = ssh_agent.SSHAgentClient()
1452
+        response_stream = io.BytesIO(response)
1453
+
1454
+        class PseudoSocket:
1455
+            def sendall(self, *args: Any, **kwargs: Any) -> Any:  # noqa: ARG002
1456
+                return None
1457
+
1458
+            def recv(self, *args: Any, **kwargs: Any) -> Any:
1459
+                return response_stream.read(*args, **kwargs)
1460
+
1461
+        pseudo_socket = PseudoSocket()
1462
+        with pytest.MonkeyPatch.context() as monkeypatch:
1463
+            monkeypatch.setattr(client, "_connection", pseudo_socket)
1464
+            with pytest.raises(EOFError):
1465
+                client.request(255, b"")
1466
+
1467
+    @Parametrize.LIST_KEYS_ERROR_RESPONSES
1468
+    def test_320_list_keys_error_responses(
1469
+        self,
1470
+        running_ssh_agent: data.RunningSSHAgentInfo,
1471
+        response_code: _types.SSH_AGENT,
1472
+        response: bytes | bytearray,
1473
+        exc_type: type[Exception],
1474
+        exc_pattern: str,
1475
+    ) -> None:
1476
+        """Fail on problems during key listing.
1477
+
1478
+        Known problems:
1479
+
1480
+          - The agent refuses, or otherwise indicates the operation
1481
+            failed.
1482
+          - The agent response is truncated.
1483
+          - The agent response is overlong.
1484
+
1485
+        """
1486
+        del running_ssh_agent
1487
+
1488
+        passed_response_code = response_code
1489
+
1490
+        # TODO(the-13th-letter): Extract this mock function into a common
1491
+        # top-level "request" mock function.
1492
+        def request(
1493
+            request_code: int | _types.SSH_AGENTC,
1494
+            payload: bytes | bytearray,
1495
+            /,
1496
+            *,
1497
+            response_code: Iterable[int | _types.SSH_AGENT]
1498
+            | int
1499
+            | _types.SSH_AGENT
1500
+            | None = None,
1501
+        ) -> tuple[int, bytes | bytearray] | bytes | bytearray:
1502
+            del request_code
1503
+            del payload
1504
+            if isinstance(  # pragma: no branch
1505
+                response_code, (int, _types.SSH_AGENT)
1506
+            ):
1507
+                response_code = frozenset({response_code})
1508
+            if response_code is not None:  # pragma: no branch
1509
+                response_code = frozenset({
1510
+                    c if isinstance(c, int) else c.value for c in response_code
1511
+                })
1512
+
1513
+            if not response_code:  # pragma: no cover
1514
+                return (passed_response_code.value, response)
1515
+            if passed_response_code.value not in response_code:
1516
+                raise ssh_agent.SSHAgentFailedError(
1517
+                    passed_response_code.value, response
1518
+                )
1519
+            return response
1520
+
1521
+        with pytest.MonkeyPatch.context() as monkeypatch:
1522
+            client = ssh_agent.SSHAgentClient()
1523
+            monkeypatch.setattr(client, "request", request)
1524
+            with pytest.raises(exc_type, match=exc_pattern):
1525
+                client.list_keys()
1526
+
1527
+    @Parametrize.SIGN_ERROR_RESPONSES
1528
+    def test_330_sign_error_responses(
1529
+        self,
1530
+        running_ssh_agent: data.RunningSSHAgentInfo,
1531
+        key: bytes | bytearray,
1532
+        check: bool,
1533
+        response_code: _types.SSH_AGENT,
1534
+        response: bytes | bytearray,
1535
+        exc_type: type[Exception],
1536
+        exc_pattern: str,
1537
+    ) -> None:
1538
+        """Fail on problems during signing.
1539
+
1540
+        Known problems:
1541
+
1542
+          - The key is not loaded into the agent.
1543
+          - The agent refuses, or otherwise indicates the operation
1544
+            failed.
1545
+
1546
+        """
1547
+        del running_ssh_agent
1548
+        passed_response_code = response_code
1549
+
1550
+        # TODO(the-13th-letter): Extract this mock function into a common
1551
+        # top-level "request" mock function.
1552
+        def request(
1553
+            request_code: int | _types.SSH_AGENTC,
1554
+            payload: bytes | bytearray,
1555
+            /,
1556
+            *,
1557
+            response_code: Iterable[int | _types.SSH_AGENT]
1558
+            | int
1559
+            | _types.SSH_AGENT
1560
+            | None = None,
1561
+        ) -> tuple[int, bytes | bytearray] | bytes | bytearray:
1562
+            del request_code
1563
+            del payload
1564
+            if isinstance(  # pragma: no branch
1565
+                response_code, (int, _types.SSH_AGENT)
1566
+            ):
1567
+                response_code = frozenset({response_code})
1568
+            if response_code is not None:  # pragma: no branch
1569
+                response_code = frozenset({
1570
+                    c if isinstance(c, int) else c.value for c in response_code
1571
+                })
1572
+
1573
+            if not response_code:  # pragma: no cover
1574
+                return (passed_response_code.value, response)
1575
+            if (
1576
+                passed_response_code.value not in response_code
1577
+            ):  # pragma: no branch
1578
+                raise ssh_agent.SSHAgentFailedError(
1579
+                    passed_response_code.value, response
1580
+                )
1581
+            return response  # pragma: no cover
1582
+
1583
+        with pytest.MonkeyPatch.context() as monkeypatch:
1584
+            client = ssh_agent.SSHAgentClient()
1585
+            monkeypatch.setattr(client, "request", request)
1586
+            Pair = _types.SSHKeyCommentPair  # noqa: N806
1587
+            com = b"no comment"
1588
+            loaded_keys = [
1589
+                Pair(v.public_key_data, com).toreadonly()
1590
+                for v in data.SUPPORTED_KEYS.values()
1591
+            ]
1592
+            monkeypatch.setattr(client, "list_keys", lambda: loaded_keys)
1593
+            with pytest.raises(exc_type, match=exc_pattern):
1594
+                client.sign(key, b"abc", check_if_key_loaded=check)
1595
+
1596
+    @Parametrize.REQUEST_ERROR_RESPONSES
1597
+    def test_340_request_error_responses(
1598
+        self,
1599
+        running_ssh_agent: data.RunningSSHAgentInfo,
1600
+        request_code: _types.SSH_AGENTC,
1601
+        response_code: _types.SSH_AGENT,
1602
+        exc_type: type[Exception],
1603
+        exc_pattern: str,
1604
+    ) -> None:
1605
+        """Fail on problems during signing.
1606
+
1607
+        Known problems:
1608
+
1609
+          - The key is not loaded into the agent.
1610
+          - The agent refuses, or otherwise indicates the operation
1611
+            failed.
1612
+
1613
+        """
1614
+        del running_ssh_agent
1615
+
1616
+        # TODO(the-13th-letter): Rewrite using parenthesized
1617
+        # with-statements.
1618
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1619
+        with contextlib.ExitStack() as stack:
1620
+            stack.enter_context(pytest.raises(exc_type, match=exc_pattern))
1621
+            client = stack.enter_context(ssh_agent.SSHAgentClient())
1622
+            client.request(request_code, b"", response_code=response_code)
1623
+
1624
+    @Parametrize.QUERY_EXTENSIONS_MALFORMED_RESPONSES
1625
+    def test_350_query_extensions_malformed_responses(
1626
+        self,
1627
+        monkeypatch: pytest.MonkeyPatch,
1628
+        running_ssh_agent: data.RunningSSHAgentInfo,
1629
+        response_data: bytes,
1630
+    ) -> None:
1631
+        """Fail on malformed responses while querying extensions."""
1632
+        del running_ssh_agent
1633
+
1634
+        # TODO(the-13th-letter): Extract this mock function into a common
1635
+        # top-level "request" mock function after removing the
1636
+        # payload-specific parts.
1637
+        def request(
1638
+            code: int | _types.SSH_AGENTC,
1639
+            payload: Buffer,
1640
+            /,
1641
+            *,
1642
+            response_code: (
1643
+                Iterable[_types.SSH_AGENT | int]
1644
+                | _types.SSH_AGENT
1645
+                | int
1646
+                | None
1647
+            ) = None,
1648
+        ) -> tuple[int, bytes] | bytes:
1649
+            request_codes = {
1650
+                _types.SSH_AGENTC.EXTENSION,
1651
+                _types.SSH_AGENTC.EXTENSION.value,
1652
+            }
1653
+            assert code in request_codes
1654
+            response_codes = {
1655
+                _types.SSH_AGENT.EXTENSION_RESPONSE,
1656
+                _types.SSH_AGENT.EXTENSION_RESPONSE.value,
1657
+                _types.SSH_AGENT.SUCCESS,
1658
+                _types.SSH_AGENT.SUCCESS.value,
1659
+            }
1660
+            assert payload == b"\x00\x00\x00\x05query"
1661
+            if response_code is None:  # pragma: no cover
1662
+                return (
1663
+                    _types.SSH_AGENT.EXTENSION_RESPONSE.value,
1664
+                    response_data,
1665
+                )
1666
+            if isinstance(  # pragma: no cover
1667
+                response_code, (_types.SSH_AGENT, int)
1668
+            ):
1669
+                assert response_code in response_codes
1670
+                return response_data
1671
+            for single_code in response_code:  # pragma: no cover
1672
+                assert single_code in response_codes
1673
+            return response_data  # pragma: no cover
1674
+
1675
+        # TODO(the-13th-letter): Rewrite using parenthesized
1676
+        # with-statements.
1677
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1678
+        with contextlib.ExitStack() as stack:
1679
+            monkeypatch2 = stack.enter_context(monkeypatch.context())
1680
+            client = stack.enter_context(ssh_agent.SSHAgentClient())
1681
+            monkeypatch2.setattr(client, "request", request)
1682
+            with pytest.raises(
1683
+                RuntimeError,
1684
+                match=r"Malformed response|does not match request",
1685
+            ):
1686
+                client.query_extensions()
... ...
@@ -1,161 +1,3 @@
1 1
 # SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2 2
 #
3 3
 # SPDX-License-Identifier: Zlib
4
-
5
-from __future__ import annotations
6
-
7
-import copy
8
-import types
9
-
10
-import hypothesis
11
-import pytest
12
-from hypothesis import strategies
13
-
14
-from derivepassphrase import _types
15
-from tests import data
16
-from tests.machinery import hypothesis as hypothesis_machinery
17
-
18
-
19
-class Parametrize(types.SimpleNamespace):
20
-    VALID_VAULT_TEST_CONFIGS = pytest.mark.parametrize(
21
-        "test_config",
22
-        [
23
-            conf
24
-            for conf in data.TEST_CONFIGS
25
-            if conf.validation_settings in {None, (True,)}
26
-        ],
27
-        ids=data.VaultTestConfig._test_id,
28
-    )
29
-    VAULT_TEST_CONFIGS = pytest.mark.parametrize(
30
-        "test_config",
31
-        data.TEST_CONFIGS,
32
-        ids=data.VaultTestConfig._test_id,
33
-    )
34
-
35
-
36
-@Parametrize.VALID_VAULT_TEST_CONFIGS
37
-def test_200_is_vault_config(test_config: data.VaultTestConfig) -> None:
38
-    """Is this vault configuration recognized as valid/invalid?
39
-
40
-    Check all test configurations that do not need custom validation
41
-    settings.
42
-
43
-    This primarily tests the [`_types.is_vault_config`][] and
44
-    [`_types.clean_up_falsy_vault_config_values`][] functions.
45
-
46
-    """
47
-    obj, comment, _ = test_config
48
-    obj = copy.deepcopy(obj)
49
-    _types.clean_up_falsy_vault_config_values(obj)
50
-    assert _types.is_vault_config(obj) == (not comment), (
51
-        "failed to complain about: " + comment
52
-        if comment
53
-        else "failed on valid example"
54
-    )
55
-
56
-
57
-@hypothesis.given(
58
-    test_config=hypothesis_machinery.smudged_vault_test_config(
59
-        config=strategies.sampled_from([
60
-            conf for conf in data.TEST_CONFIGS if conf.is_valid()
61
-        ])
62
-    )
63
-)
64
-def test_200a_is_vault_config_smudged(
65
-    test_config: data.VaultTestConfig,
66
-) -> None:
67
-    """Is this vault configuration recognized as valid/invalid?
68
-
69
-    Generate test data via hypothesis by smudging all valid test
70
-    configurations.
71
-
72
-    This primarily tests the [`_types.is_vault_config`][] and
73
-    [`_types.clean_up_falsy_vault_config_values`][] functions.
74
-
75
-    """
76
-    obj_, comment, _ = test_config
77
-    obj = copy.deepcopy(obj_)
78
-    did_cleanup = _types.clean_up_falsy_vault_config_values(obj)
79
-    assert _types.is_vault_config(obj) == (not comment), (
80
-        "failed to complain about: " + comment
81
-        if comment
82
-        else "failed on valid example"
83
-    )
84
-    assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), (
85
-        "mismatched report on cleanup work"
86
-    )
87
-
88
-
89
-@Parametrize.VAULT_TEST_CONFIGS
90
-def test_400_validate_vault_config(
91
-    test_config: data.VaultTestConfig,
92
-) -> None:
93
-    """Validate this vault configuration.
94
-
95
-    Check all test configurations, including those with non-standard
96
-    validation settings.
97
-
98
-    This primarily tests the [`_types.validate_vault_config`][] and
99
-    [`_types.clean_up_falsy_vault_config_values`][] functions.
100
-
101
-    """
102
-    obj, comment, validation_settings = test_config
103
-    (allow_unknown_settings,) = validation_settings or (True,)
104
-    obj = copy.deepcopy(obj)
105
-    _types.clean_up_falsy_vault_config_values(obj)
106
-    if comment:
107
-        with pytest.raises((TypeError, ValueError)):
108
-            _types.validate_vault_config(
109
-                obj,
110
-                allow_unknown_settings=allow_unknown_settings,
111
-            )
112
-    else:
113
-        try:
114
-            _types.validate_vault_config(
115
-                obj,
116
-                allow_unknown_settings=allow_unknown_settings,
117
-            )
118
-        except (TypeError, ValueError) as exc:  # pragma: no cover
119
-            assert not exc, "failed to validate valid example"  # noqa: PT017
120
-
121
-
122
-@hypothesis.given(
123
-    test_config=hypothesis_machinery.smudged_vault_test_config(
124
-        config=strategies.sampled_from([
125
-            conf for conf in data.TEST_CONFIGS if conf.is_smudgable()
126
-        ])
127
-    )
128
-)
129
-def test_400a_validate_vault_config_smudged(
130
-    test_config: data.VaultTestConfig,
131
-) -> None:
132
-    """Validate this vault configuration.
133
-
134
-    Generate test data via hypothesis by smudging all smudgable test
135
-    configurations.
136
-
137
-    This primarily tests the [`_types.validate_vault_config`][] and
138
-    [`_types.clean_up_falsy_vault_config_values`][] functions.
139
-
140
-    """
141
-    obj_, comment, validation_settings = test_config
142
-    (allow_unknown_settings,) = validation_settings or (True,)
143
-    obj = copy.deepcopy(obj_)
144
-    did_cleanup = _types.clean_up_falsy_vault_config_values(obj)
145
-    if comment:
146
-        with pytest.raises((TypeError, ValueError)):
147
-            _types.validate_vault_config(
148
-                obj,
149
-                allow_unknown_settings=allow_unknown_settings,
150
-            )
151
-    else:
152
-        try:
153
-            _types.validate_vault_config(
154
-                obj,
155
-                allow_unknown_settings=allow_unknown_settings,
156
-            )
157
-        except (TypeError, ValueError) as exc:  # pragma: no cover
158
-            assert not exc, "failed to validate valid example"  # noqa: PT017
159
-    assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), (
160
-        "mismatched report on cleanup work"
161
-    )
... ...
@@ -0,0 +1,161 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+from __future__ import annotations
6
+
7
+import copy
8
+import types
9
+
10
+import hypothesis
11
+import pytest
12
+from hypothesis import strategies
13
+
14
+from derivepassphrase import _types
15
+from tests import data
16
+from tests.machinery import hypothesis as hypothesis_machinery
17
+
18
+
19
+class Parametrize(types.SimpleNamespace):
20
+    VALID_VAULT_TEST_CONFIGS = pytest.mark.parametrize(
21
+        "test_config",
22
+        [
23
+            conf
24
+            for conf in data.TEST_CONFIGS
25
+            if conf.validation_settings in {None, (True,)}
26
+        ],
27
+        ids=data.VaultTestConfig._test_id,
28
+    )
29
+    VAULT_TEST_CONFIGS = pytest.mark.parametrize(
30
+        "test_config",
31
+        data.TEST_CONFIGS,
32
+        ids=data.VaultTestConfig._test_id,
33
+    )
34
+
35
+
36
+@Parametrize.VALID_VAULT_TEST_CONFIGS
37
+def test_200_is_vault_config(test_config: data.VaultTestConfig) -> None:
38
+    """Is this vault configuration recognized as valid/invalid?
39
+
40
+    Check all test configurations that do not need custom validation
41
+    settings.
42
+
43
+    This primarily tests the [`_types.is_vault_config`][] and
44
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
45
+
46
+    """
47
+    obj, comment, _ = test_config
48
+    obj = copy.deepcopy(obj)
49
+    _types.clean_up_falsy_vault_config_values(obj)
50
+    assert _types.is_vault_config(obj) == (not comment), (
51
+        "failed to complain about: " + comment
52
+        if comment
53
+        else "failed on valid example"
54
+    )
55
+
56
+
57
+@hypothesis.given(
58
+    test_config=hypothesis_machinery.smudged_vault_test_config(
59
+        config=strategies.sampled_from([
60
+            conf for conf in data.TEST_CONFIGS if conf.is_valid()
61
+        ])
62
+    )
63
+)
64
+def test_200a_is_vault_config_smudged(
65
+    test_config: data.VaultTestConfig,
66
+) -> None:
67
+    """Is this vault configuration recognized as valid/invalid?
68
+
69
+    Generate test data via hypothesis by smudging all valid test
70
+    configurations.
71
+
72
+    This primarily tests the [`_types.is_vault_config`][] and
73
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
74
+
75
+    """
76
+    obj_, comment, _ = test_config
77
+    obj = copy.deepcopy(obj_)
78
+    did_cleanup = _types.clean_up_falsy_vault_config_values(obj)
79
+    assert _types.is_vault_config(obj) == (not comment), (
80
+        "failed to complain about: " + comment
81
+        if comment
82
+        else "failed on valid example"
83
+    )
84
+    assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), (
85
+        "mismatched report on cleanup work"
86
+    )
87
+
88
+
89
+@Parametrize.VAULT_TEST_CONFIGS
90
+def test_400_validate_vault_config(
91
+    test_config: data.VaultTestConfig,
92
+) -> None:
93
+    """Validate this vault configuration.
94
+
95
+    Check all test configurations, including those with non-standard
96
+    validation settings.
97
+
98
+    This primarily tests the [`_types.validate_vault_config`][] and
99
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
100
+
101
+    """
102
+    obj, comment, validation_settings = test_config
103
+    (allow_unknown_settings,) = validation_settings or (True,)
104
+    obj = copy.deepcopy(obj)
105
+    _types.clean_up_falsy_vault_config_values(obj)
106
+    if comment:
107
+        with pytest.raises((TypeError, ValueError)):
108
+            _types.validate_vault_config(
109
+                obj,
110
+                allow_unknown_settings=allow_unknown_settings,
111
+            )
112
+    else:
113
+        try:
114
+            _types.validate_vault_config(
115
+                obj,
116
+                allow_unknown_settings=allow_unknown_settings,
117
+            )
118
+        except (TypeError, ValueError) as exc:  # pragma: no cover
119
+            assert not exc, "failed to validate valid example"  # noqa: PT017
120
+
121
+
122
+@hypothesis.given(
123
+    test_config=hypothesis_machinery.smudged_vault_test_config(
124
+        config=strategies.sampled_from([
125
+            conf for conf in data.TEST_CONFIGS if conf.is_smudgable()
126
+        ])
127
+    )
128
+)
129
+def test_400a_validate_vault_config_smudged(
130
+    test_config: data.VaultTestConfig,
131
+) -> None:
132
+    """Validate this vault configuration.
133
+
134
+    Generate test data via hypothesis by smudging all smudgable test
135
+    configurations.
136
+
137
+    This primarily tests the [`_types.validate_vault_config`][] and
138
+    [`_types.clean_up_falsy_vault_config_values`][] functions.
139
+
140
+    """
141
+    obj_, comment, validation_settings = test_config
142
+    (allow_unknown_settings,) = validation_settings or (True,)
143
+    obj = copy.deepcopy(obj_)
144
+    did_cleanup = _types.clean_up_falsy_vault_config_values(obj)
145
+    if comment:
146
+        with pytest.raises((TypeError, ValueError)):
147
+            _types.validate_vault_config(
148
+                obj,
149
+                allow_unknown_settings=allow_unknown_settings,
150
+            )
151
+    else:
152
+        try:
153
+            _types.validate_vault_config(
154
+                obj,
155
+                allow_unknown_settings=allow_unknown_settings,
156
+            )
157
+        except (TypeError, ValueError) as exc:  # pragma: no cover
158
+            assert not exc, "failed to validate valid example"  # noqa: PT017
159
+    assert did_cleanup is None or bool(did_cleanup) == (obj != obj_), (
160
+        "mismatched report on cleanup work"
161
+    )
0 162