Split the CLI tests into one file per class/group
Marco Ricci

Marco Ricci commited on 2025-08-09 16:22:42
Zeige 5 geänderte Dateien mit 4745 Einfügungen und 4597 Löschungen.


The CLI tests, already loosely grouped through the use of classes, are
now distributed to different files, one file per test class.

This is mostly an attempt to keep the file size managable. Navigating in
a multi-thousand-line Python file with very similar looking tests gets
disorienting very quickly.
... ...
@@ -4,30 +4,19 @@
4 4
 
5 5
 from __future__ import annotations
6 6
 
7
-import base64
8 7
 import contextlib
9 8
 import copy
10
-import ctypes
11
-import enum
12 9
 import errno
13
-import io
14 10
 import json
15
-import logging
16
-import operator
17 11
 import os
18 12
 import pathlib
19
-import re
20
-import shlex
21 13
 import shutil
22 14
 import socket
23
-import tempfile
24 15
 import textwrap
25 16
 import types
26
-import warnings
27 17
 from typing import TYPE_CHECKING
28 18
 
29 19
 import click.testing
30
-import exceptiongroup
31 20
 import hypothesis
32 21
 import pytest
33 22
 from hypothesis import strategies
... ...
@@ -36,18 +25,14 @@ from typing_extensions import Any, NamedTuple
36 25
 from derivepassphrase import _types, cli, ssh_agent, vault
37 26
 from derivepassphrase._internals import (
38 27
     cli_helpers,
39
-    cli_machinery,
40 28
     cli_messages,
41 29
 )
42
-from derivepassphrase.ssh_agent import socketprovider
43 30
 from tests import data, machinery
44 31
 from tests.data import callables
45 32
 from tests.machinery import hypothesis as hypothesis_machinery
46 33
 from tests.machinery import pytest as pytest_machinery
47 34
 
48 35
 if TYPE_CHECKING:
49
-    from collections.abc import Callable, Iterable, Iterator, Sequence
50
-    from collections.abc import Set as AbstractSet
51 36
     from typing import NoReturn
52 37
 
53 38
     from typing_extensions import Literal
... ...
@@ -90,43 +75,6 @@ class OptionCombination(NamedTuple):
90 75
     check_success: bool
91 76
 
92 77
 
93
-class VersionOutputData(NamedTuple):
94
-    derivation_schemes: dict[str, bool]
95
-    foreign_configuration_formats: dict[str, bool]
96
-    extras: frozenset[str]
97
-    subcommands: frozenset[str]
98
-    features: dict[str, bool]
99
-
100
-
101
-class KnownLineType(str, enum.Enum):
102
-    SUPPORTED_FOREIGN_CONFS = cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip(
103
-        ":"
104
-    )
105
-    UNAVAILABLE_FOREIGN_CONFS = cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip(
106
-        ":"
107
-    )
108
-    SUPPORTED_SCHEMES = (
109
-        cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES.value.singular.rstrip(
110
-            ":"
111
-        )
112
-    )
113
-    UNAVAILABLE_SCHEMES = cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES.value.singular.rstrip(
114
-        ":"
115
-    )
116
-    SUPPORTED_SUBCOMMANDS = (
117
-        cli_messages.Label.SUPPORTED_SUBCOMMANDS.value.singular.rstrip(":")
118
-    )
119
-    SUPPORTED_FEATURES = (
120
-        cli_messages.Label.SUPPORTED_FEATURES.value.singular.rstrip(":")
121
-    )
122
-    UNAVAILABLE_FEATURES = (
123
-        cli_messages.Label.UNAVAILABLE_FEATURES.value.singular.rstrip(":")
124
-    )
125
-    ENABLED_EXTRAS = (
126
-        cli_messages.Label.ENABLED_PEP508_EXTRAS.value.singular.rstrip(":")
127
-    )
128
-
129
-
130 78
 PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
131 79
     ("--phrase",),
132 80
     ("--key",),
... ...
@@ -320,535 +268,12 @@ def assert_vault_config_is_indented_and_line_broken(
320 268
     ])
321 269
 
322 270
 
323
-def vault_config_exporter_shell_interpreter(  # noqa: C901
324
-    script: str | Iterable[str],
325
-    /,
326
-    *,
327
-    prog_name_list: list[str] | None = None,
328
-    command: click.BaseCommand | None = None,
329
-    runner: machinery.CliRunner | None = None,
330
-) -> Iterator[machinery.ReadableResult]:
331
-    """A rudimentary sh(1) interpreter for `--export-as=sh` output.
332
-
333
-    Assumes a script as emitted by `derivepassphrase vault
334
-    --export-as=sh --export -` and interprets the calls to
335
-    `derivepassphrase vault` within.  (One call per line, skips all
336
-    other lines.)  Also has rudimentary support for (quoted)
337
-    here-documents using `HERE` as the marker.
338
-
339
-    """
340
-    if isinstance(script, str):  # pragma: no cover
341
-        script = script.splitlines(False)
342
-    if prog_name_list is None:  # pragma: no cover
343
-        prog_name_list = ["derivepassphrase", "vault"]
344
-    if command is None:  # pragma: no cover
345
-        command = cli.derivepassphrase_vault
346
-    if runner is None:  # pragma: no cover
347
-        runner = machinery.CliRunner(mix_stderr=False)
348
-    n = len(prog_name_list)
349
-    it = iter(script)
350
-    while True:
351
-        try:
352
-            raw_line = next(it)
353
-        except StopIteration:
354
-            break
355
-        else:
356
-            line = shlex.split(raw_line)
357
-        input_buffer: list[str] = []
358
-        if line[:n] != prog_name_list:
359
-            continue
360
-        line[:n] = []
361
-        if line and line[-1] == "<<HERE":
362
-            # naive HERE document support
363
-            while True:
364
-                try:
365
-                    raw_line = next(it)
366
-                except StopIteration as exc:  # pragma: no cover
367
-                    msg = "incomplete here document"
368
-                    raise EOFError(msg) from exc
369
-                else:
370
-                    if raw_line == "HERE":
371
-                        break
372
-                    input_buffer.append(raw_line)
373
-            line.pop()
374
-        yield runner.invoke(
375
-            command,
376
-            line,
377
-            catch_exceptions=False,
378
-            input=("".join(x + "\n" for x in input_buffer) or None),
379
-        )
380
-
381
-
382
-def parse_version_output(  # noqa: C901
383
-    version_output: str,
384
-    /,
385
-    *,
386
-    prog_name: str | None = cli_messages.PROG_NAME,
387
-    version: str | None = cli_messages.VERSION,
388
-) -> VersionOutputData:
389
-    r"""Parse the output of the `--version` option.
390
-
391
-    The version output contains two paragraphs.  The first paragraph
392
-    details the version number, and the version number of any major
393
-    libraries in use.  The second paragraph details known and supported
394
-    passphrase derivation schemes, foreign configuration formats,
395
-    subcommands and PEP 508 package extras.  For the schemes and
396
-    formats, there is a "supported" line for supported items, and
397
-    a "known" line for known but currently unsupported items (usually
398
-    because of missing dependencies), either of which may be empty and
399
-    thus omitted.  For extras, only active items are shown, and there is
400
-    a separate message for the "no extras active" case.  Item lists may
401
-    be spilled across multiple lines, but only at item boundaries, and
402
-    the continuation lines are then indented.
403
-
404
-    Args:
405
-        version_output:
406
-            The version output text to parse.
407
-        prog_name:
408
-            The program name to assert, defaulting to the true program
409
-            name, `derivepassphrase`.  Set to `None` to disable this
410
-            check.
411
-        version:
412
-            The program version to assert, defaulting to the true
413
-            current version of `derivepassphrase`.  Set to `None` to
414
-            disable this check.
415
-
416
-    Examples:
417
-        See [`Parametrize.VERSION_OUTPUT_DATA`][].
418
-
419
-    """
420
-    paragraphs: list[list[str]] = []
421
-    paragraph: list[str] = []
422
-    for line in version_output.splitlines(keepends=False):
423
-        if not line.strip():
424
-            if paragraph:
425
-                paragraphs.append(paragraph.copy())
426
-            paragraph.clear()
427
-        elif paragraph and line.lstrip() != line:
428
-            paragraph[-1] = f"{paragraph[-1]} {line.lstrip()}"
429
-        else:
430
-            paragraph.append(line)
431
-    if paragraph:  # pragma: no branch
432
-        paragraphs.append(paragraph.copy())
433
-        paragraph.clear()
434
-    assert paragraphs, (
435
-        f"expected at least one paragraph of version output: {paragraphs!r}"
436
-    )
437
-    assert prog_name is None or prog_name in paragraphs[0][0], (
438
-        f"first version output line should mention "
439
-        f"{prog_name}: {paragraphs[0][0]!r}"
440
-    )
441
-    assert version is None or version in paragraphs[0][0], (
442
-        f"first version output line should mention the version number "
443
-        f"{version}: {paragraphs[0][0]!r}"
444
-    )
445
-    schemes: dict[str, bool] = {}
446
-    formats: dict[str, bool] = {}
447
-    subcommands: set[str] = set()
448
-    extras: set[str] = set()
449
-    features: dict[str, bool] = {}
450
-    if len(paragraphs) < 2:  # pragma: no cover
451
-        return VersionOutputData(
452
-            derivation_schemes=schemes,
453
-            foreign_configuration_formats=formats,
454
-            subcommands=frozenset(subcommands),
455
-            extras=frozenset(extras),
456
-            features=features,
457
-        )
458
-    for line in paragraphs[1]:
459
-        line_type, _, value = line.partition(":")
460
-        if line_type == line:
461
-            continue
462
-        for item_ in re.split(r"(?:, *|.$)", value):
463
-            item = item_.strip()
464
-            if not item:
465
-                continue
466
-            if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS:
467
-                formats[item] = True
468
-            elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS:
469
-                formats[item] = False
470
-            elif line_type == KnownLineType.SUPPORTED_SCHEMES:
471
-                schemes[item] = True
472
-            elif line_type == KnownLineType.UNAVAILABLE_SCHEMES:
473
-                schemes[item] = False
474
-            elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS:
475
-                subcommands.add(item)
476
-            elif line_type == KnownLineType.ENABLED_EXTRAS:
477
-                extras.add(item)
478
-            elif line_type == KnownLineType.SUPPORTED_FEATURES:
479
-                features[item] = True
480
-            elif line_type == KnownLineType.UNAVAILABLE_FEATURES:
481
-                features[item] = False
482
-            else:
483
-                raise AssertionError(  # noqa: TRY003
484
-                    f"Unknown version info line type: {line_type!r}"  # noqa: EM102
485
-                )
486
-    return VersionOutputData(
487
-        derivation_schemes=schemes,
488
-        foreign_configuration_formats=formats,
489
-        subcommands=frozenset(subcommands),
490
-        extras=frozenset(extras),
491
-        features=features,
492
-    )
493
-
494
-
495
-def bash_format(item: click.shell_completion.CompletionItem) -> str:
496
-    """A formatter for `bash`-style shell completion items.
497
-
498
-    The format is `type,value`, and is dictated by [`click`][].
499
-
500
-    """
501
-    type, value = (  # noqa: A001
502
-        item.type,
503
-        item.value,
504
-    )
505
-    return f"{type},{value}"
506
-
507
-
508
-def fish_format(item: click.shell_completion.CompletionItem) -> str:
509
-    r"""A formatter for `fish`-style shell completion items.
510
-
511
-    The format is `type,value<tab>help`, and is dictated by [`click`][].
512
-
513
-    """
514
-    type, value, help = (  # noqa: A001
515
-        item.type,
516
-        item.value,
517
-        item.help,
518
-    )
519
-    return f"{type},{value}\t{help}" if help else f"{type},{value}"
520
-
521
-
522
-def zsh_format(item: click.shell_completion.CompletionItem) -> str:
523
-    r"""A formatter for `zsh`-style shell completion items.
524
-
525
-    The format is `type<newline>value<newline>help<newline>`, and is
526
-    dictated by [`click`][].  Upstream `click` currently (v8.2.0) does
527
-    not deal with colons in the value correctly when the help text is
528
-    non-degenerate.  Our formatter here does, provided the upstream
529
-    `zsh` completion script is used; see the
530
-    [`cli_machinery.ZshComplete`][] class.  A request is underway to
531
-    merge this change into upstream `click`; see
532
-    [`pallets/click#2846`][PR2846].
533
-
534
-    [PR2846]: https://github.com/pallets/click/pull/2846
535
-
536
-    """
537
-    empty_help = "_"
538
-    help_, value = (
539
-        (item.help, item.value.replace(":", r"\:"))
540
-        if item.help and item.help == empty_help
541
-        else (empty_help, item.value)
542
-    )
543
-    return f"{item.type}\n{value}\n{help_}"
544
-
545
-
546
-class ListKeysAction(str, enum.Enum):
547
-    """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][].
548
-
549
-    Attributes:
550
-        EMPTY: Return an empty key list.
551
-        FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
552
-        FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
553
-
554
-    """
555
-
556
-    EMPTY = enum.auto()
557
-    """"""
558
-    FAIL = enum.auto()
559
-    """"""
560
-    FAIL_RUNTIME = enum.auto()
561
-    """"""
562
-
563
-    def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
564
-        """Execute the respective action."""
565
-        # TODO(the-13th-letter): Rewrite using structural pattern
566
-        # matching.
567
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
568
-        if self == self.EMPTY:
569
-            return []
570
-        if self == self.FAIL:
571
-            raise ssh_agent.SSHAgentFailedError(
572
-                _types.SSH_AGENT.FAILURE.value, b""
573
-            )
574
-        if self == self.FAIL_RUNTIME:
575
-            raise ssh_agent.TrailingDataError()
576
-        raise AssertionError()
577
-
578
-
579
-class SignAction(str, enum.Enum):
580
-    """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][].
581
-
582
-    Attributes:
583
-        FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
584
-        FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
585
-
586
-    """
587
-
588
-    FAIL = enum.auto()
589
-    """"""
590
-    FAIL_RUNTIME = enum.auto()
591
-    """"""
592
-
593
-    def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
594
-        """Execute the respective action."""
595
-        # TODO(the-13th-letter): Rewrite using structural pattern
596
-        # matching.
597
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
598
-        if self == self.FAIL:
599
-            raise ssh_agent.SSHAgentFailedError(
600
-                _types.SSH_AGENT.FAILURE.value, b""
601
-            )
602
-        if self == self.FAIL_RUNTIME:
603
-            raise ssh_agent.TrailingDataError()
604
-        raise AssertionError()
605
-
606
-
607
-class SocketAddressAction(str, enum.Enum):
608
-    """Test fixture settings for the SSH agent socket address.
609
-
610
-    Attributes:
611
-        MANGLE_ANNOYING_OS_NAMED_PIPE:
612
-            Mangle the address for the Annoying OS named pipe endpoint.
613
-        MANGLE_SSH_AUTH_SOCK:
614
-            Mangle the address for the UNIX domain socket (the
615
-            `SSH_AUTH_SOCK` environment variable).
616
-        UNSET_ANNOYING_OS_NAMED_PIPE:
617
-            Unset the address for the Annoying OS named pipe endpoint.
618
-        UNSET_SSH_AUTH_SOCK:
619
-            Unset the `SSH_AUTH_SOCK` environment variable (the address
620
-            for the UNIX domain socket).
621
-
622
-    """
623
-
624
-    MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto()
625
-    """"""
626
-    MANGLE_SSH_AUTH_SOCK = enum.auto()
627
-    """"""
628
-    UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto()
629
-    """"""
630
-    UNSET_SSH_AUTH_SOCK = enum.auto()
631
-    """"""
632
-
633
-    def __call__(
634
-        self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
635
-    ) -> None:
636
-        """Execute the respective action."""
637
-        # TODO(the-13th-letter): Rewrite using structural pattern
638
-        # matching.
639
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
640
-        if self in {
641
-            self.MANGLE_ANNOYING_OS_NAMED_PIPE,
642
-            self.UNSET_ANNOYING_OS_NAMED_PIPE,
643
-        }:  # pragma: no cover [unused]
644
-            pass
645
-        elif self == self.MANGLE_SSH_AUTH_SOCK:
646
-            monkeypatch.setenv(
647
-                "SSH_AUTH_SOCK", os.environ["SSH_AUTH_SOCK"] + "~"
648
-            )
649
-        elif self == self.UNSET_SSH_AUTH_SOCK:
650
-            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
651
-        else:
652
-            raise AssertionError()
653
-
654
-
655
-class SystemSupportAction(str, enum.Enum):
656
-    """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support.
657
-
658
-    Attributes:
659
-        UNSET_AF_UNIX:
660
-            Ensure lack of support for UNIX domain sockets.
661
-        UNSET_AF_UNIX_AND_ENSURE_USE:
662
-            Ensure lack of support for UNIX domain sockets, and that the
663
-            agent will use this socket provider.
664
-        UNSET_NATIVE:
665
-            Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`.
666
-        UNSET_NATIVE_AND_ENSURE_USE:
667
-            Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the
668
-            agent will use the native socket provider.
669
-        UNSET_PROVIDER_LIST:
670
-            Ensure an empty list of SSH agent socket providers.
671
-        UNSET_WINDLL:
672
-            Ensure lack of support for The Annoying OS named pipes.
673
-        UNSET_WINDLL_AND_ENSURE_USE:
674
-            Ensure lack of support for The Annoying OS named pipes, and
675
-            that the agent will use this socket provider.
676
-
677
-    """
678
-
679
-    UNSET_AF_UNIX = enum.auto()
680
-    """"""
681
-    UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto()
682
-    """"""
683
-    UNSET_NATIVE = enum.auto()
684
-    """"""
685
-    UNSET_NATIVE_AND_ENSURE_USE = enum.auto()
686
-    """"""
687
-    UNSET_PROVIDER_LIST = enum.auto()
688
-    """"""
689
-    UNSET_WINDLL = enum.auto()
690
-    """"""
691
-    UNSET_WINDLL_AND_ENSURE_USE = enum.auto()
692
-    """"""
693
-
694
-    def __call__(
695
-        self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
696
-    ) -> None:
697
-        """Execute the respective action.
698
-
699
-        Args:
700
-            monkeypatch: The current monkeypatch context.
701
-
702
-        """
703
-        # TODO(the-13th-letter): Rewrite using structural pattern
704
-        # matching.
705
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
706
-        if self == self.UNSET_PROVIDER_LIST:
707
-            monkeypatch.setattr(
708
-                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", []
709
-            )
710
-        elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}:
711
-            self.check_or_ensure_use(
712
-                "native",
713
-                monkeypatch=monkeypatch,
714
-                ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE),
715
-            )
716
-            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
717
-            monkeypatch.delattr(ctypes, "WinDLL", raising=False)
718
-            monkeypatch.delattr(ctypes, "windll", raising=False)
719
-        elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}:
720
-            self.check_or_ensure_use(
721
-                "posix",
722
-                monkeypatch=monkeypatch,
723
-                ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE),
724
-            )
725
-            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
726
-        elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}:
727
-            self.check_or_ensure_use(
728
-                "the_annoying_os",
729
-                monkeypatch=monkeypatch,
730
-                ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE),
731
-            )
732
-            monkeypatch.delattr(ctypes, "WinDLL", raising=False)
733
-            monkeypatch.delattr(ctypes, "windll", raising=False)
734
-        else:
735
-            raise AssertionError()
736
-
737
-    @staticmethod
738
-    def check_or_ensure_use(
739
-        provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool
740
-    ) -> None:
741
-        """Check that the named SSH agent socket provider will be used.
742
-
743
-        Either ensure that the socket provider will definitely be used,
744
-        or, upon detecting that it won't be used, skip the test.
745
-
746
-        Args:
747
-            provider:
748
-                The provider to check for.
749
-            ensure_use:
750
-                If true, ensure that the socket provider will definitely
751
-                be used.  If false, then check for whether it will be
752
-                used, and skip this test if not.
753
-            monkeypatch:
754
-                The monkeypatch context within which the fixture
755
-                adjustments should be executed.
756
-
757
-        """
758
-        if ensure_use:
759
-            monkeypatch.setattr(
760
-                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [provider]
761
-            )
762
-        else:  # pragma: no cover [external]
763
-            # This branch operates completely on instrumented or on
764
-            # externally defined, non-deterministic state.
765
-            intended: (
766
-                _types.SSHAgentSocketProvider
767
-                | socketprovider.NoSuchProviderError
768
-                | None
769
-            )
770
-            try:
771
-                intended = socketprovider.SocketProvider.lookup(provider)
772
-            except socketprovider.NoSuchProviderError as exc:
773
-                intended = exc
774
-            actual: (
775
-                _types.SSHAgentSocketProvider
776
-                | socketprovider.NoSuchProviderError
777
-                | None
778
-            )
779
-            for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS:
780
-                try:
781
-                    actual = socketprovider.SocketProvider.lookup(name)
782
-                except socketprovider.NoSuchProviderError as exc:
783
-                    actual = exc
784
-                if actual is None:
785
-                    continue
786
-                break
787
-            else:
788
-                actual = None
789
-            if intended != actual:
790
-                pytest.skip(
791
-                    f"{provider!r} SSH agent socket provider "
792
-                    f"is not currently in use"
793
-                )
794
-
795
-
796 271
 class Parametrize(types.SimpleNamespace):
797 272
     """Common test parametrizations."""
798 273
 
799
-    EAGER_ARGUMENTS = pytest.mark.parametrize(
800
-        "arguments",
801
-        [["--help"], ["--version"]],
802
-        ids=["help", "version"],
803
-    )
804 274
     CHARSET_NAME = pytest.mark.parametrize(
805 275
         "charset_name", ["lower", "upper", "number", "space", "dash", "symbol"]
806 276
     )
807
-    COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize(
808
-        ["command", "non_eager_arguments"],
809
-        [
810
-            pytest.param(
811
-                [],
812
-                [],
813
-                id="top-nothing",
814
-            ),
815
-            pytest.param(
816
-                [],
817
-                ["export"],
818
-                id="top-export",
819
-            ),
820
-            pytest.param(
821
-                ["export"],
822
-                [],
823
-                id="export-nothing",
824
-            ),
825
-            pytest.param(
826
-                ["export"],
827
-                ["vault"],
828
-                id="export-vault",
829
-            ),
830
-            pytest.param(
831
-                ["export", "vault"],
832
-                [],
833
-                id="export-vault-nothing",
834
-            ),
835
-            pytest.param(
836
-                ["export", "vault"],
837
-                ["--format", "this-format-doesnt-exist"],
838
-                id="export-vault-args",
839
-            ),
840
-            pytest.param(
841
-                ["vault"],
842
-                [],
843
-                id="vault-nothing",
844
-            ),
845
-            pytest.param(
846
-                ["vault"],
847
-                ["--export", "./"],
848
-                id="vault-args",
849
-            ),
850
-        ],
851
-    )
852 277
     UNICODE_NORMALIZATION_COMMAND_LINES = pytest.mark.parametrize(
853 278
         "command_line",
854 279
         [
... ...
@@ -866,45 +291,6 @@ class Parametrize(types.SimpleNamespace):
866 291
             ),
867 292
         ],
868 293
     )
869
-    DELETE_CONFIG_INPUT = pytest.mark.parametrize(
870
-        ["command_line", "config", "result_config"],
871
-        [
872
-            pytest.param(
873
-                ["--delete-globals"],
874
-                {"global": {"phrase": "abc"}, "services": {}},
875
-                {"services": {}},
876
-                id="globals",
877
-            ),
878
-            pytest.param(
879
-                ["--delete", "--", DUMMY_SERVICE],
880
-                {
881
-                    "global": {"phrase": "abc"},
882
-                    "services": {DUMMY_SERVICE: {"notes": "..."}},
883
-                },
884
-                {"global": {"phrase": "abc"}, "services": {}},
885
-                id="service",
886
-            ),
887
-            pytest.param(
888
-                ["--clear"],
889
-                {
890
-                    "global": {"phrase": "abc"},
891
-                    "services": {DUMMY_SERVICE: {"notes": "..."}},
892
-                },
893
-                {"services": {}},
894
-                id="all",
895
-            ),
896
-        ],
897
-    )
898
-    COLORFUL_COMMAND_INPUT = pytest.mark.parametrize(
899
-        ["command_line", "input"],
900
-        [
901
-            (
902
-                ["vault", "--import", "-"],
903
-                '{"services": {"": {"length": 20}}}',
904
-            ),
905
-        ],
906
-        ids=["cmd"],
907
-    )
908 294
     CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES = pytest.mark.parametrize(
909 295
         ["command_line", "input", "err_text"],
910 296
         [
... ...
@@ -993,182 +379,6 @@ class Parametrize(types.SimpleNamespace):
993 379
             ),
994 380
         ],
995 381
     )
996
-    COMPLETABLE_PATH_ARGUMENT = pytest.mark.parametrize(
997
-        "command_prefix",
998
-        [
999
-            pytest.param(
1000
-                ("export", "vault"),
1001
-                id="derivepassphrase-export-vault",
1002
-            ),
1003
-            pytest.param(
1004
-                ("vault", "--export"),
1005
-                id="derivepassphrase-vault--export",
1006
-            ),
1007
-            pytest.param(
1008
-                ("vault", "--import"),
1009
-                id="derivepassphrase-vault--import",
1010
-            ),
1011
-        ],
1012
-    )
1013
-    COMPLETABLE_OPTIONS = pytest.mark.parametrize(
1014
-        ["command_prefix", "incomplete", "completions"],
1015
-        [
1016
-            pytest.param(
1017
-                (),
1018
-                "-",
1019
-                frozenset({
1020
-                    "--help",
1021
-                    "-h",
1022
-                    "--version",
1023
-                    "--debug",
1024
-                    "--verbose",
1025
-                    "-v",
1026
-                    "--quiet",
1027
-                    "-q",
1028
-                }),
1029
-                id="derivepassphrase",
1030
-            ),
1031
-            pytest.param(
1032
-                ("export",),
1033
-                "-",
1034
-                frozenset({
1035
-                    "--help",
1036
-                    "-h",
1037
-                    "--version",
1038
-                    "--debug",
1039
-                    "--verbose",
1040
-                    "-v",
1041
-                    "--quiet",
1042
-                    "-q",
1043
-                }),
1044
-                id="derivepassphrase-export",
1045
-            ),
1046
-            pytest.param(
1047
-                ("export", "vault"),
1048
-                "-",
1049
-                frozenset({
1050
-                    "--help",
1051
-                    "-h",
1052
-                    "--version",
1053
-                    "--debug",
1054
-                    "--verbose",
1055
-                    "-v",
1056
-                    "--quiet",
1057
-                    "-q",
1058
-                    "--format",
1059
-                    "-f",
1060
-                    "--key",
1061
-                    "-k",
1062
-                }),
1063
-                id="derivepassphrase-export-vault",
1064
-            ),
1065
-            pytest.param(
1066
-                ("vault",),
1067
-                "-",
1068
-                frozenset({
1069
-                    "--help",
1070
-                    "-h",
1071
-                    "--version",
1072
-                    "--debug",
1073
-                    "--verbose",
1074
-                    "-v",
1075
-                    "--quiet",
1076
-                    "-q",
1077
-                    "--phrase",
1078
-                    "-p",
1079
-                    "--key",
1080
-                    "-k",
1081
-                    "--length",
1082
-                    "-l",
1083
-                    "--repeat",
1084
-                    "-r",
1085
-                    "--upper",
1086
-                    "--lower",
1087
-                    "--number",
1088
-                    "--space",
1089
-                    "--dash",
1090
-                    "--symbol",
1091
-                    "--config",
1092
-                    "-c",
1093
-                    "--notes",
1094
-                    "-n",
1095
-                    "--delete",
1096
-                    "-x",
1097
-                    "--delete-globals",
1098
-                    "--clear",
1099
-                    "-X",
1100
-                    "--export",
1101
-                    "-e",
1102
-                    "--import",
1103
-                    "-i",
1104
-                    "--overwrite-existing",
1105
-                    "--merge-existing",
1106
-                    "--unset",
1107
-                    "--export-as",
1108
-                    "--modern-editor-interface",
1109
-                    "--vault-legacy-editor-interface",
1110
-                    "--print-notes-before",
1111
-                    "--print-notes-after",
1112
-                }),
1113
-                id="derivepassphrase-vault",
1114
-            ),
1115
-        ],
1116
-    )
1117
-    COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize(
1118
-        ["command_prefix", "incomplete", "completions"],
1119
-        [
1120
-            pytest.param(
1121
-                (),
1122
-                "",
1123
-                frozenset({"export", "vault"}),
1124
-                id="derivepassphrase",
1125
-            ),
1126
-            pytest.param(
1127
-                ("export",),
1128
-                "",
1129
-                frozenset({"vault"}),
1130
-                id="derivepassphrase-export",
1131
-            ),
1132
-        ],
1133
-    )
1134
-    BAD_CONFIGS = pytest.mark.parametrize(
1135
-        "config",
1136
-        [
1137
-            {"global": "", "services": {}},
1138
-            {"global": 0, "services": {}},
1139
-            {
1140
-                "global": {"phrase": "abc"},
1141
-                "services": False,
1142
-            },
1143
-            {
1144
-                "global": {"phrase": "abc"},
1145
-                "services": True,
1146
-            },
1147
-            {
1148
-                "global": {"phrase": "abc"},
1149
-                "services": None,
1150
-            },
1151
-        ],
1152
-    )
1153
-    BASE_CONFIG_VARIATIONS = pytest.mark.parametrize(
1154
-        "config",
1155
-        [
1156
-            {"global": {"phrase": "my passphrase"}, "services": {}},
1157
-            {"global": {"key": DUMMY_KEY1_B64}, "services": {}},
1158
-            {
1159
-                "global": {"phrase": "abc"},
1160
-                "services": {"sv": {"phrase": "my passphrase"}},
1161
-            },
1162
-            {
1163
-                "global": {"phrase": "abc"},
1164
-                "services": {"sv": {"key": DUMMY_KEY1_B64}},
1165
-            },
1166
-            {
1167
-                "global": {"phrase": "abc"},
1168
-                "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
1169
-            },
1170
-        ],
1171
-    )
1172 382
     BASE_CONFIG_WITH_KEY_VARIATIONS = pytest.mark.parametrize(
1173 383
         "config",
1174 384
         [
... ...
@@ -1253,404 +463,24 @@ class Parametrize(types.SimpleNamespace):
1253 463
             ),
1254 464
         ],
1255 465
     )
1256
-    COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize(
1257
-        ["config", "comp_func", "args", "incomplete", "results"],
1258
-        [
1259
-            pytest.param(
1260
-                {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1261
-                cli_helpers.shell_complete_service,
1262
-                ["vault"],
1263
-                "",
1264
-                [DUMMY_SERVICE],
1265
-                id="base_config-service",
1266
-            ),
1267
-            pytest.param(
1268
-                {"services": {}},
1269
-                cli_helpers.shell_complete_service,
1270
-                ["vault"],
1271
-                "",
1272
-                [],
1273
-                id="empty_config-service",
1274
-            ),
1275
-            pytest.param(
1276
-                {
1277
-                    "services": {
1278
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1279
-                        "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(),
1280
-                    }
1281
-                },
1282
-                cli_helpers.shell_complete_service,
1283
-                ["vault"],
1284
-                "",
1285
-                [DUMMY_SERVICE],
1286
-                id="incompletable_newline_config-service",
1287
-            ),
1288
-            pytest.param(
1289
-                {
1290
-                    "services": {
1291
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1292
-                        "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(),
1293
-                    }
1294
-                },
1295
-                cli_helpers.shell_complete_service,
1296
-                ["vault"],
1297
-                "",
1298
-                [DUMMY_SERVICE],
1299
-                id="incompletable_backspace_config-service",
1300
-            ),
1301
-            pytest.param(
1302
-                {
1303
-                    "services": {
1304
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1305
-                        "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(),
1306
-                    }
1307
-                },
1308
-                cli_helpers.shell_complete_service,
1309
-                ["vault"],
1310
-                "",
1311
-                sorted([DUMMY_SERVICE, "colon:in:name"]),
1312
-                id="brittle_colon_config-service",
1313
-            ),
1314
-            pytest.param(
1315
-                {
1316
-                    "services": {
1317
-                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1318
-                        "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(),
1319
-                        "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(),
1320
-                        "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(),
1321
-                        "nul\x00in\x00name": DUMMY_CONFIG_SETTINGS.copy(),
1322
-                        "del\x7fin\x7fname": DUMMY_CONFIG_SETTINGS.copy(),
1323
-                    }
1324
-                },
1325
-                cli_helpers.shell_complete_service,
1326
-                ["vault"],
1327
-                "",
1328
-                sorted([DUMMY_SERVICE, "colon:in:name"]),
1329
-                id="brittle_incompletable_multi_config-service",
1330
-            ),
1331
-            pytest.param(
1332
-                {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1333
-                cli_helpers.shell_complete_path,
1334
-                ["vault", "--import"],
1335
-                "",
1336
-                [click.shell_completion.CompletionItem("", type="file")],
1337
-                id="base_config-path",
1338
-            ),
1339
-            pytest.param(
1340
-                {"services": {}},
1341
-                cli_helpers.shell_complete_path,
1342
-                ["vault", "--import"],
1343
-                "",
1344
-                [click.shell_completion.CompletionItem("", type="file")],
1345
-                id="empty_config-path",
1346
-            ),
1347
-        ],
1348
-    )
1349
-    COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize(
1350
-        ["config", "incomplete", "completions"],
1351
-        [
1352
-            pytest.param(
1353
-                {"services": {}},
1354
-                "",
1355
-                frozenset(),
1356
-                id="no_services",
1357
-            ),
1358
-            pytest.param(
1359
-                {"services": {}},
1360
-                "partial",
1361
-                frozenset(),
1362
-                id="no_services_partial",
1363
-            ),
1364
-            pytest.param(
1365
-                {"services": {DUMMY_SERVICE: {"length": 10}}},
1366
-                "",
1367
-                frozenset({DUMMY_SERVICE}),
1368
-                id="one_service",
1369
-            ),
1370
-            pytest.param(
1371
-                {"services": {DUMMY_SERVICE: {"length": 10}}},
1372
-                DUMMY_SERVICE[:4],
1373
-                frozenset({DUMMY_SERVICE}),
1374
-                id="one_service_partial",
1375
-            ),
1376
-            pytest.param(
1377
-                {"services": {DUMMY_SERVICE: {"length": 10}}},
1378
-                DUMMY_SERVICE[-4:],
1379
-                frozenset(),
1380
-                id="one_service_partial_miss",
1381
-            ),
1382
-        ],
1383
-    )
1384
-    SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize(
1385
-        ["config", "key", "incomplete", "completions"],
1386
-        [
1387
-            pytest.param(
1388
-                {
1389
-                    "services": {
1390
-                        DUMMY_SERVICE: {"length": 10},
1391
-                        "newline\nin\nname": {"length": 10},
1392
-                    },
1393
-                },
1394
-                "newline\nin\nname",
1395
-                "",
1396
-                frozenset({DUMMY_SERVICE}),
1397
-                id="newline",
1398
-            ),
1399
-            pytest.param(
1400
-                {
1401
-                    "services": {
1402
-                        DUMMY_SERVICE: {"length": 10},
1403
-                        "newline\nin\nname": {"length": 10},
1404
-                    },
1405
-                },
1406
-                "newline\nin\nname",
1407
-                "serv",
1408
-                frozenset({DUMMY_SERVICE}),
1409
-                id="newline_partial_other",
1410
-            ),
1411
-            pytest.param(
1412
-                {
1413
-                    "services": {
1414
-                        DUMMY_SERVICE: {"length": 10},
1415
-                        "newline\nin\nname": {"length": 10},
1416
-                    },
1417
-                },
1418
-                "newline\nin\nname",
1419
-                "newline",
1420
-                frozenset({}),
1421
-                id="newline_partial_specific",
1422
-            ),
1423
-            pytest.param(
1424
-                {
1425
-                    "services": {
1426
-                        DUMMY_SERVICE: {"length": 10},
1427
-                        "nul\x00in\x00name": {"length": 10},
1428
-                    },
1429
-                },
1430
-                "nul\x00in\x00name",
1431
-                "",
1432
-                frozenset({DUMMY_SERVICE}),
1433
-                id="nul",
1434
-            ),
1435
-            pytest.param(
1436
-                {
1437
-                    "services": {
1438
-                        DUMMY_SERVICE: {"length": 10},
1439
-                        "nul\x00in\x00name": {"length": 10},
1440
-                    },
1441
-                },
1442
-                "nul\x00in\x00name",
1443
-                "serv",
1444
-                frozenset({DUMMY_SERVICE}),
1445
-                id="nul_partial_other",
1446
-            ),
1447
-            pytest.param(
1448
-                {
1449
-                    "services": {
1450
-                        DUMMY_SERVICE: {"length": 10},
1451
-                        "nul\x00in\x00name": {"length": 10},
1452
-                    },
1453
-                },
1454
-                "nul\x00in\x00name",
1455
-                "nul",
1456
-                frozenset({}),
1457
-                id="nul_partial_specific",
1458
-            ),
1459
-            pytest.param(
1460
-                {
1461
-                    "services": {
1462
-                        DUMMY_SERVICE: {"length": 10},
1463
-                        "backspace\bin\bname": {"length": 10},
1464
-                    },
1465
-                },
1466
-                "backspace\bin\bname",
1467
-                "",
1468
-                frozenset({DUMMY_SERVICE}),
1469
-                id="backspace",
1470
-            ),
1471
-            pytest.param(
1472
-                {
1473
-                    "services": {
1474
-                        DUMMY_SERVICE: {"length": 10},
1475
-                        "backspace\bin\bname": {"length": 10},
1476
-                    },
1477
-                },
1478
-                "backspace\bin\bname",
1479
-                "serv",
1480
-                frozenset({DUMMY_SERVICE}),
1481
-                id="backspace_partial_other",
1482
-            ),
1483
-            pytest.param(
1484
-                {
1485
-                    "services": {
1486
-                        DUMMY_SERVICE: {"length": 10},
1487
-                        "backspace\bin\bname": {"length": 10},
1488
-                    },
1489
-                },
1490
-                "backspace\bin\bname",
1491
-                "back",
1492
-                frozenset({}),
1493
-                id="backspace_partial_specific",
1494
-            ),
1495
-            pytest.param(
1496
-                {
1497
-                    "services": {
1498
-                        DUMMY_SERVICE: {"length": 10},
1499
-                        "del\x7fin\x7fname": {"length": 10},
1500
-                    },
1501
-                },
1502
-                "del\x7fin\x7fname",
1503
-                "",
1504
-                frozenset({DUMMY_SERVICE}),
1505
-                id="del",
1506
-            ),
1507
-            pytest.param(
1508
-                {
1509
-                    "services": {
1510
-                        DUMMY_SERVICE: {"length": 10},
1511
-                        "del\x7fin\x7fname": {"length": 10},
1512
-                    },
1513
-                },
1514
-                "del\x7fin\x7fname",
1515
-                "serv",
1516
-                frozenset({DUMMY_SERVICE}),
1517
-                id="del_partial_other",
1518
-            ),
1519
-            pytest.param(
1520
-                {
1521
-                    "services": {
1522
-                        DUMMY_SERVICE: {"length": 10},
1523
-                        "del\x7fin\x7fname": {"length": 10},
1524
-                    },
1525
-                },
1526
-                "del\x7fin\x7fname",
1527
-                "del",
1528
-                frozenset({}),
1529
-                id="del_partial_specific",
1530
-            ),
1531
-        ],
1532
-    )
1533
-    CONNECTION_HINTS = pytest.mark.parametrize(
1534
-        "conn_hint", ["none", "socket", "client"]
1535
-    )
1536
-    NOOP_EDIT_FUNCS = pytest.mark.parametrize(
1537
-        ["edit_func_name", "modern_editor_interface"],
1538
-        [
1539
-            pytest.param("empty", True, id="empty"),
1540
-            pytest.param("space", False, id="space-legacy"),
1541
-            pytest.param("space", True, id="space-modern"),
1542
-        ],
1543
-    )
1544
-    SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize(
1545
-        "exc_type", [RuntimeError, KeyError, ValueError]
1546
-    )
1547
-    EXPORT_FORMAT_OPTIONS = pytest.mark.parametrize(
1548
-        "export_options",
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",
1549 476
         [
1550 477
             [],
1551 478
             ["--export-as=sh"],
1552 479
         ],
1553 480
     )
1554
-    INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"])
1555
-    ISATTY = pytest.mark.parametrize(
1556
-        "isatty",
1557
-        [False, True],
1558
-        ids=["notty", "tty"],
1559
-    )
1560 481
     KEY_INDEX = pytest.mark.parametrize(
1561 482
         "key_index", [1, 2, 3], ids=lambda i: f"index{i}"
1562 483
     )
1563
-    KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize(
1564
-        [
1565
-            "list_keys_action",
1566
-            "address_action",
1567
-            "system_support_action",
1568
-            "sign_action",
1569
-            "pattern",
1570
-        ],
1571
-        [
1572
-            pytest.param(
1573
-                ListKeysAction.EMPTY,
1574
-                None,
1575
-                None,
1576
-                SignAction.FAIL,
1577
-                "not loaded into the agent",
1578
-                id="key-not-loaded",
1579
-            ),
1580
-            pytest.param(
1581
-                ListKeysAction.FAIL,
1582
-                None,
1583
-                None,
1584
-                SignAction.FAIL,
1585
-                "SSH agent failed to or refused to",
1586
-                id="list-keys-refused",
1587
-            ),
1588
-            pytest.param(
1589
-                ListKeysAction.FAIL_RUNTIME,
1590
-                None,
1591
-                None,
1592
-                SignAction.FAIL,
1593
-                "SSH agent failed to or refused to",
1594
-                id="list-keys-protocol-error",
1595
-            ),
1596
-            pytest.param(
1597
-                None,
1598
-                SocketAddressAction.UNSET_SSH_AUTH_SOCK,
1599
-                None,
1600
-                SignAction.FAIL,
1601
-                "Cannot find any running SSH agent",
1602
-                id="agent-address-missing",
1603
-            ),
1604
-            pytest.param(
1605
-                None,
1606
-                SocketAddressAction.MANGLE_SSH_AUTH_SOCK,
1607
-                None,
1608
-                SignAction.FAIL,
1609
-                "Cannot connect to the SSH agent",
1610
-                id="agent-address-mangled",
1611
-            ),
1612
-            pytest.param(
1613
-                None,
1614
-                None,
1615
-                SystemSupportAction.UNSET_NATIVE,
1616
-                SignAction.FAIL,
1617
-                "does not support communicating with it",
1618
-                id="no-agent-support",
1619
-            ),
1620
-            pytest.param(
1621
-                None,
1622
-                None,
1623
-                SystemSupportAction.UNSET_PROVIDER_LIST,
1624
-                SignAction.FAIL,
1625
-                "does not support communicating with it",
1626
-                id="no-agent-support",
1627
-            ),
1628
-            pytest.param(
1629
-                None,
1630
-                None,
1631
-                SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE,
1632
-                SignAction.FAIL,
1633
-                "does not support communicating with it",
1634
-                id="no-agent-support",
1635
-            ),
1636
-            pytest.param(
1637
-                None,
1638
-                None,
1639
-                SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE,
1640
-                SignAction.FAIL,
1641
-                "does not support communicating with it",
1642
-                id="no-agent-support",
1643
-            ),
1644
-            pytest.param(
1645
-                None,
1646
-                None,
1647
-                None,
1648
-                SignAction.FAIL_RUNTIME,
1649
-                "violates the communication protocol",
1650
-                id="sign-violates-protocol",
1651
-            ),
1652
-        ],
1653
-    )
1654 484
     UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
1655 485
         ["main_config", "command_line", "input", "error_message"],
1656 486
         [
... ...
@@ -1791,9 +621,6 @@ class Parametrize(types.SimpleNamespace):
1791 621
             ),
1792 622
         ],
1793 623
     )
1794
-    MASK_PROG_NAME = pytest.mark.parametrize("mask_prog_name", [False, True])
1795
-    MASK_VERSION = pytest.mark.parametrize("mask_version", [False, True])
1796
-    CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"])
1797 624
     MODERN_EDITOR_INTERFACE = pytest.mark.parametrize(
1798 625
         "modern_editor_interface", [False, True], ids=["legacy", "modern"]
1799 626
     )
... ...
@@ -1839,181 +666,18 @@ class Parametrize(types.SimpleNamespace):
1839 666
             if not o.incompatible
1840 667
         ],
1841 668
     )
1842
-    COMPLETABLE_ITEMS = pytest.mark.parametrize(
1843
-        ["partial", "is_completable"],
1844
-        [
1845
-            ("", True),
1846
-            (DUMMY_SERVICE, True),
1847
-            ("a\bn", False),
1848
-            ("\b", False),
1849
-            ("\x00", False),
1850
-            ("\x20", True),
1851
-            ("\x7f", False),
1852
-            ("service with spaces", True),
1853
-            ("service\nwith\nnewlines", False),
1854
-        ],
1855
-    )
1856
-    SHELL_FORMATTER = pytest.mark.parametrize(
1857
-        ["shell", "format_func"],
1858
-        [
1859
-            pytest.param("bash", bash_format, id="bash"),
1860
-            pytest.param("fish", fish_format, id="fish"),
1861
-            pytest.param("zsh", zsh_format, id="zsh"),
1862
-        ],
1863
-    )
1864 669
     TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
1865 670
         "try_race_free_implementation", [True, False]
1866 671
     )
1867
-    VERSION_OUTPUT_DATA = pytest.mark.parametrize(
1868
-        ["version_output", "prog_name", "version", "expected_parse"],
1869
-        [
1870
-            pytest.param(
1871
-                """\
1872
-derivepassphrase 0.4.0
1873
-Using cryptography 44.0.0
1874
-
1875
-Supported foreign configuration formats: vault storeroom, vault v0.2,
1876
-    vault v0.3.
1877
-PEP 508 extras: export.
1878
-""",
1879
-                "derivepassphrase",
1880
-                "0.4.0",
1881
-                VersionOutputData(
1882
-                    derivation_schemes={},
1883
-                    foreign_configuration_formats={
1884
-                        "vault storeroom": True,
1885
-                        "vault v0.2": True,
1886
-                        "vault v0.3": True,
1887
-                    },
1888
-                    subcommands=frozenset(),
1889
-                    features={},
1890
-                    extras=frozenset({"export"}),
1891
-                ),
1892
-                id="derivepassphrase-0.4.0-export",
1893
-            ),
1894
-            pytest.param(
1895
-                """\
1896
-derivepassphrase 0.5
1897
-
1898
-Supported derivation schemes: vault.
1899
-Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3.
1900
-Supported subcommands: export, vault.
1901
-No PEP 508 extras are active.
1902
-""",
1903
-                "derivepassphrase",
1904
-                "0.5",
1905
-                VersionOutputData(
1906
-                    derivation_schemes={"vault": True},
1907
-                    foreign_configuration_formats={
1908
-                        "vault storeroom": False,
1909
-                        "vault v0.2": False,
1910
-                        "vault v0.3": False,
1911
-                    },
1912
-                    subcommands=frozenset({"export", "vault"}),
1913
-                    features={},
1914
-                    extras=frozenset({}),
1915
-                ),
1916
-                id="derivepassphrase-0.5-plain",
1917
-            ),
1918
-            pytest.param(
1919
-                """\
1920
-
1921
-
1922
-
1923
-inventpassphrase -1.3
1924
-Using not-a-library 7.12
1925
-Copyright 2025 Nobody.  All rights reserved.
1926
-
1927
-Supported derivation schemes: nonsense.
1928
-Known derivation schemes: divination, /dev/random,
1929
-    geiger counter,
1930
-    crossword solver.
1931
-Supported foreign configuration formats: derivepassphrase, nonsense.
1932
-Known foreign configuration formats: divination v3.141592,
1933
-    /dev/random.
1934
-Supported subcommands: delete-all-files, dump-core.
1935
-Supported features: delete-while-open.
1936
-Known features: backups-are-nice-to-have.
1937
-PEP 508 extras: annoying-popups, delete-all-files,
1938
-    dump-core-depending-on-the-phase-of-the-moon.
1939
-
1940
-
1941
-
1942
-""",
1943
-                "inventpassphrase",
1944
-                "-1.3",
1945
-                VersionOutputData(
1946
-                    derivation_schemes={
1947
-                        "nonsense": True,
1948
-                        "divination": False,
1949
-                        "/dev/random": False,
1950
-                        "geiger counter": False,
1951
-                        "crossword solver": False,
1952
-                    },
1953
-                    foreign_configuration_formats={
1954
-                        "derivepassphrase": True,
1955
-                        "nonsense": True,
1956
-                        "divination v3.141592": False,
1957
-                        "/dev/random": False,
1958
-                    },
1959
-                    subcommands=frozenset({"delete-all-files", "dump-core"}),
1960
-                    features={
1961
-                        "delete-while-open": True,
1962
-                        "backups-are-nice-to-have": False,
1963
-                    },
1964
-                    extras=frozenset({
1965
-                        "annoying-popups",
1966
-                        "delete-all-files",
1967
-                        "dump-core-depending-on-the-phase-of-the-moon",
1968
-                    }),
1969
-                ),
1970
-                id="inventpassphrase",
1971
-            ),
1972
-        ],
1973
-    )
1974
-    """Sample data for [`parse_version_output`][]."""
1975
-    VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize(
1976
-        ["vfunc", "input"],
1977
-        [
1978
-            (cli_machinery.validate_occurrence_constraint, 20),
1979
-            (cli_machinery.validate_length, 20),
1980
-        ],
1981
-    )
1982 672
 
1983 673
 
1984
-class TestAllCLI:
1985
-    """Tests uniformly for all command-line interfaces."""
674
+class TestCLI:
675
+    """Tests for the `derivepassphrase vault` command-line interface."""
1986 676
 
1987
-    @Parametrize.MASK_PROG_NAME
1988
-    @Parametrize.MASK_VERSION
1989
-    @Parametrize.VERSION_OUTPUT_DATA
1990
-    def test_001_parse_version_output(
677
+    def test_200_help_output(
1991 678
         self,
1992
-        version_output: str,
1993
-        prog_name: str | None,
1994
-        version: str | None,
1995
-        mask_prog_name: bool,
1996
-        mask_version: bool,
1997
-        expected_parse: VersionOutputData,
1998 679
     ) -> None:
1999
-        """The parsing machinery for expected version output data works."""
2000
-        prog_name = None if mask_prog_name else prog_name
2001
-        version = None if mask_version else version
2002
-        assert (
2003
-            parse_version_output(
2004
-                version_output, prog_name=prog_name, version=version
2005
-            )
2006
-            == expected_parse
2007
-        )
2008
-
2009
-    # TODO(the-13th-letter): Do we actually need this?  What should we
2010
-    # check for?
2011
-    def test_100_help_output(self) -> None:
2012
-        """The top-level help text mentions subcommands.
2013
-
2014
-        TODO: Do we actually need this?  What should we check for?
2015
-
2016
-        """
680
+        """The `--help` option emits help text."""
2017 681
         runner = machinery.CliRunner(mix_stderr=False)
2018 682
         # TODO(the-13th-letter): Rewrite using parenthesized
2019 683
         # with-statements.
... ...
@@ -2027,22 +691,23 @@ class TestAllCLI:
2027 691
                 )
2028 692
             )
2029 693
             result = runner.invoke(
2030
-                cli.derivepassphrase, ["--help"], catch_exceptions=False
694
+                cli.derivepassphrase_vault,
695
+                ["--help"],
696
+                catch_exceptions=False,
2031 697
             )
2032 698
         assert result.clean_exit(
2033
-            empty_stderr=True, output="currently implemented subcommands"
2034
-        ), "expected clean exit, and known help text"
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"
2035 704
 
2036
-    # TODO(the-13th-letter): Do we actually need this?  What should we
2037
-    # check for?
2038
-    def test_101_help_output_export(
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(
2039 708
         self,
2040 709
     ) -> None:
2041
-        """The "export" subcommand help text mentions subcommands.
2042
-
2043
-        TODO: Do we actually need this?  What should we check for?
2044
-
2045
-        """
710
+        """The `--version` option emits version information."""
2046 711
         runner = machinery.CliRunner(mix_stderr=False)
2047 712
         # TODO(the-13th-letter): Rewrite using parenthesized
2048 713
         # with-statements.
... ...
@@ -2056,24 +721,25 @@ class TestAllCLI:
2056 721
                 )
2057 722
             )
2058 723
             result = runner.invoke(
2059
-                cli.derivepassphrase,
2060
-                ["export", "--help"],
724
+                cli.derivepassphrase_vault,
725
+                ["--version"],
2061 726
                 catch_exceptions=False,
2062 727
             )
2063
-        assert result.clean_exit(
2064
-            empty_stderr=True, output="only available subcommand"
2065
-        ), "expected clean exit, and known help text"
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
+        )
2066 734
 
2067
-    # TODO(the-13th-letter): Do we actually need this?  What should we
2068
-    # check for?
2069
-    def test_102_help_output_export_vault(
735
+    @Parametrize.CHARSET_NAME
736
+    def test_201_disable_character_set(
2070 737
         self,
738
+        charset_name: str,
2071 739
     ) -> None:
2072
-        """The "export vault" subcommand help text has known content.
2073
-
2074
-        TODO: Do we actually need this?  What should we check for?
2075
-
2076
-        """
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")
2077 743
         runner = machinery.CliRunner(mix_stderr=False)
2078 744
         # TODO(the-13th-letter): Rewrite using parenthesized
2079 745
         # with-statements.
... ...
@@ -2086,25 +752,27 @@ class TestAllCLI:
2086 752
                     runner=runner,
2087 753
                 )
2088 754
             )
755
+            monkeypatch.setattr(
756
+                cli_helpers,
757
+                "prompt_for_passphrase",
758
+                callables.auto_prompt,
759
+            )
2089 760
             result = runner.invoke(
2090
-                cli.derivepassphrase,
2091
-                ["export", "vault", "--help"],
761
+                cli.derivepassphrase_vault,
762
+                [option, "0", "-p", "--", DUMMY_SERVICE],
763
+                input=DUMMY_PASSPHRASE,
2092 764
                 catch_exceptions=False,
2093 765
             )
2094
-        assert result.clean_exit(
2095
-            empty_stderr=True, output="Export a vault-native configuration"
2096
-        ), "expected clean exit, and known help text"
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
+            )
2097 771
 
2098
-    # TODO(the-13th-letter): Do we actually need this?  What should we
2099
-    # check for?
2100
-    def test_103_help_output_vault(
772
+    def test_202_disable_repetition(
2101 773
         self,
2102 774
     ) -> None:
2103
-        """The "vault" subcommand help text has known content.
2104
-
2105
-        TODO: Do we actually need this?  What should we check for?
2106
-
2107
-        """
775
+        """Character repetition can be disabled on the command-line."""
2108 776
         runner = machinery.CliRunner(mix_stderr=False)
2109 777
         # TODO(the-13th-letter): Rewrite using parenthesized
2110 778
         # with-statements.
... ...
@@ -2117,27 +785,35 @@ class TestAllCLI:
2117 785
                     runner=runner,
2118 786
                 )
2119 787
             )
788
+            monkeypatch.setattr(
789
+                cli_helpers,
790
+                "prompt_for_passphrase",
791
+                callables.auto_prompt,
792
+            )
2120 793
             result = runner.invoke(
2121
-                cli.derivepassphrase,
2122
-                ["vault", "--help"],
794
+                cli.derivepassphrase_vault,
795
+                ["--repeat", "0", "-p", "--", DUMMY_SERVICE],
796
+                input=DUMMY_PASSPHRASE,
2123 797
                 catch_exceptions=False,
2124 798
             )
2125
-        assert result.clean_exit(
2126
-            empty_stderr=True, output="Passphrase generation:\n"
2127
-        ), "expected clean exit, and option groups in help text"
2128
-        assert result.clean_exit(
2129
-            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
2130
-        ), "expected clean exit, and option group epilog in help text"
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
+            )
2131 808
 
2132
-    @Parametrize.COMMAND_NON_EAGER_ARGUMENTS
2133
-    @Parametrize.EAGER_ARGUMENTS
2134
-    def test_200_eager_options(
809
+    @Parametrize.CONFIG_WITH_KEY
810
+    def test_204a_key_from_config(
2135 811
         self,
2136
-        command: list[str],
2137
-        arguments: list[str],
2138
-        non_eager_arguments: list[str],
812
+        running_ssh_agent: data.RunningSSHAgentInfo,
813
+        config: _types.VaultConfig,
2139 814
     ) -> None:
2140
-        """Eager options terminate option and argument processing."""
815
+        """A stored configured SSH key will be used."""
816
+        del running_ssh_agent
2141 817
         runner = machinery.CliRunner(mix_stderr=False)
2142 818
         # TODO(the-13th-letter): Rewrite using parenthesized
2143 819
         # with-statements.
... ...
@@ -2145,34 +821,40 @@ class TestAllCLI:
2145 821
         with contextlib.ExitStack() as stack:
2146 822
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2147 823
             stack.enter_context(
2148
-                pytest_machinery.isolated_config(
824
+                pytest_machinery.isolated_vault_config(
2149 825
                     monkeypatch=monkeypatch,
2150 826
                     runner=runner,
827
+                    vault_config=config,
2151 828
                 )
2152 829
             )
830
+            monkeypatch.setattr(
831
+                vault.Vault,
832
+                "phrase_from_key",
833
+                callables.phrase_from_key,
834
+            )
2153 835
             result = runner.invoke(
2154
-                cli.derivepassphrase,
2155
-                [*command, *arguments, *non_eager_arguments],
836
+                cli.derivepassphrase_vault,
837
+                ["--", DUMMY_SERVICE],
2156 838
                 catch_exceptions=False,
2157 839
             )
2158
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
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"
2159 851
 
2160
-    @Parametrize.ISATTY
2161
-    @Parametrize.COLORFUL_COMMAND_INPUT
2162
-    def test_201_automatic_color_mode(
852
+    def test_204b_key_from_command_line(
2163 853
         self,
2164
-        isatty: bool,
2165
-        command_line: list[str],
2166
-        input: str | None,
854
+        running_ssh_agent: data.RunningSSHAgentInfo,
2167 855
     ) -> None:
2168
-        """Auto-detect if color should be used.
2169
-
2170
-        (The answer currently is always no. See the
2171
-        [`conventional-configurable-text-styling` wishlist
2172
-        entry](../wishlist/conventional-configurable-text-styling.md).)
2173
-
2174
-        """
2175
-        color = False
856
+        """An SSH key requested on the command-line will be used."""
857
+        del running_ssh_agent
2176 858
         runner = machinery.CliRunner(mix_stderr=False)
2177 859
         # TODO(the-13th-letter): Rewrite using parenthesized
2178 860
         # with-statements.
... ...
@@ -2180,40 +862,50 @@ class TestAllCLI:
2180 862
         with contextlib.ExitStack() as stack:
2181 863
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2182 864
             stack.enter_context(
2183
-                pytest_machinery.isolated_config(
865
+                pytest_machinery.isolated_vault_config(
2184 866
                     monkeypatch=monkeypatch,
2185 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,
2186 877
             )
878
+            monkeypatch.setattr(
879
+                vault.Vault,
880
+                "phrase_from_key",
881
+                callables.phrase_from_key,
2187 882
             )
2188 883
             result = runner.invoke(
2189
-                cli.derivepassphrase,
2190
-                command_line,
2191
-                input=input,
884
+                cli.derivepassphrase_vault,
885
+                ["-k", "--", DUMMY_SERVICE],
886
+                input="1\n",
2192 887
                 catch_exceptions=False,
2193
-                color=isatty,
2194 888
             )
889
+        assert result.clean_exit(), "expected clean exit"
890
+        assert result.stdout, "expected program output"
891
+        last_line = result.stdout.splitlines(True)[-1]
2195 892
         assert (
2196
-            not color
2197
-            or "\x1b[0m" in result.stderr
2198
-            or "\x1b[m" in result.stderr
2199
-        ), "Expected color, but found no ANSI reset sequence"
2200
-        assert color or "\x1b[" not in result.stderr, (
2201
-            "Expected no color, but found an ANSI control sequence"
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"
2202 897
         )
2203 898
 
2204
-    def test_202a_derivepassphrase_version_option_output(
899
+    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
900
+    @Parametrize.KEY_INDEX
901
+    def test_204c_key_override_on_command_line(
2205 902
         self,
903
+        running_ssh_agent: data.RunningSSHAgentInfo,
904
+        config: dict[str, Any],
905
+        key_index: int,
2206 906
     ) -> None:
2207
-        """The version output states supported features.
2208
-
2209
-        The version output is parsed using [`parse_version_output`][].
2210
-        Format examples can be found in
2211
-        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
2212
-        top-level `derivepassphrase` command, the output should contain
2213
-        the known and supported derivation schemes, and a list of
2214
-        subcommands.
2215
-
2216
-        """
907
+        """A command-line SSH key will override the configured key."""
908
+        del running_ssh_agent
2217 909
         runner = machinery.CliRunner(mix_stderr=False)
2218 910
         # TODO(the-13th-letter): Rewrite using parenthesized
2219 911
         # with-statements.
... ...
@@ -2221,40 +913,38 @@ class TestAllCLI:
2221 913
         with contextlib.ExitStack() as stack:
2222 914
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2223 915
             stack.enter_context(
2224
-                pytest_machinery.isolated_config(
916
+                pytest_machinery.isolated_vault_config(
2225 917
                     monkeypatch=monkeypatch,
2226 918
                     runner=runner,
919
+                    vault_config=config,
920
+                )
2227 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
2228 929
             )
2229 930
             result = runner.invoke(
2230
-                cli.derivepassphrase,
2231
-                ["--version"],
2232
-                catch_exceptions=False,
931
+                cli.derivepassphrase_vault,
932
+                ["-k", "--", DUMMY_SERVICE],
933
+                input=f"{key_index}\n",
2233 934
             )
2234
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
2235
-        assert result.stdout.strip(), "expected version output"
2236
-        version_data = parse_version_output(result.stdout)
2237
-        actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True)
2238
-        subcommands = set(_types.Subcommand)
2239
-        assert version_data.derivation_schemes == actually_known_schemes
2240
-        assert not version_data.foreign_configuration_formats
2241
-        assert version_data.subcommands == subcommands
2242
-        assert not version_data.features
2243
-        assert not version_data.extras
2244
-
2245
-    def test_202b_export_version_option_output(
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(
2246 943
         self,
944
+        running_ssh_agent: data.RunningSSHAgentInfo,
2247 945
     ) -> None:
2248
-        """The version output states supported features.
2249
-
2250
-        The version output is parsed using [`parse_version_output`][].
2251
-        Format examples can be found in
2252
-        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
2253
-        `export` command, the output should contain the known foreign
2254
-        configuration formats (but not marked as supported), and a list
2255
-        of subcommands.
2256
-
2257
-        """
946
+        """A command-line passphrase will override the configured key."""
947
+        del running_ssh_agent
2258 948
         runner = machinery.CliRunner(mix_stderr=False)
2259 949
         # TODO(the-13th-letter): Rewrite using parenthesized
2260 950
         # with-statements.
... ...
@@ -2262,47 +952,53 @@ class TestAllCLI:
2262 952
         with contextlib.ExitStack() as stack:
2263 953
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2264 954
             stack.enter_context(
2265
-                pytest_machinery.isolated_config(
955
+                pytest_machinery.isolated_vault_config(
2266 956
                     monkeypatch=monkeypatch,
2267 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
+                )
2268 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
2269 976
             )
2270 977
             result = runner.invoke(
2271
-                cli.derivepassphrase,
2272
-                ["export", "--version"],
978
+                cli.derivepassphrase_vault,
979
+                ["--", DUMMY_SERVICE],
2273 980
                 catch_exceptions=False,
2274 981
             )
2275
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
2276
-        assert result.stdout.strip(), "expected version output"
2277
-        version_data = parse_version_output(result.stdout)
2278
-        actually_known_formats: dict[str, bool] = {
2279
-            _types.ForeignConfigurationFormat.VAULT_STOREROOM: False,
2280
-            _types.ForeignConfigurationFormat.VAULT_V02: False,
2281
-            _types.ForeignConfigurationFormat.VAULT_V03: False,
2282
-        }
2283
-        subcommands = set(_types.ExportSubcommand)
2284
-        assert not version_data.derivation_schemes
982
+        assert result.clean_exit(), "expected clean exit"
983
+        assert result.stdout, "expected program output"
984
+        last_line = result.stdout.splitlines(True)[-1]
2285 985
         assert (
2286
-            version_data.foreign_configuration_formats
2287
-            == actually_known_formats
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"
2288 990
         )
2289
-        assert version_data.subcommands == subcommands
2290
-        assert not version_data.features
2291
-        assert not version_data.extras
2292 991
 
2293
-    def test_202c_export_vault_version_option_output(
992
+    @Parametrize.KEY_OVERRIDING_IN_CONFIG
993
+    def test_206_setting_phrase_thus_overriding_key_in_config(
2294 994
         self,
995
+        running_ssh_agent: data.RunningSSHAgentInfo,
996
+        caplog: pytest.LogCaptureFixture,
997
+        config: _types.VaultConfig,
998
+        command_line: list[str],
2295 999
     ) -> None:
2296
-        """The version output states supported features.
2297
-
2298
-        The version output is parsed using [`parse_version_output`][].
2299
-        Format examples can be found in
2300
-        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
2301
-        `export vault` subcommand, the output should contain the
2302
-        vault-specific subset of the known or supported foreign
2303
-        configuration formats, and a list of available PEP 508 extras.
2304
-
2305
-        """
1000
+        """Configuring a passphrase atop an SSH key works, but warns."""
1001
+        del running_ssh_agent
2306 1002
         runner = machinery.CliRunner(mix_stderr=False)
2307 1003
         # TODO(the-13th-letter): Rewrite using parenthesized
2308 1004
         # with-statements.
... ...
@@ -2310,54 +1006,59 @@ class TestAllCLI:
2310 1006
         with contextlib.ExitStack() as stack:
2311 1007
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2312 1008
             stack.enter_context(
2313
-                pytest_machinery.isolated_config(
1009
+                pytest_machinery.isolated_vault_config(
2314 1010
                     monkeypatch=monkeypatch,
2315 1011
                     runner=runner,
1012
+                    vault_config=config,
1013
+                )
1014
+            )
1015
+            monkeypatch.setattr(
1016
+                ssh_agent.SSHAgentClient,
1017
+                "list_keys",
1018
+                callables.list_keys,
2316 1019
             )
1020
+            monkeypatch.setattr(
1021
+                ssh_agent.SSHAgentClient, "sign", callables.sign
2317 1022
             )
2318 1023
             result = runner.invoke(
2319
-                cli.derivepassphrase,
2320
-                ["export", "vault", "--version"],
1024
+                cli.derivepassphrase_vault,
1025
+                command_line,
1026
+                input=DUMMY_PASSPHRASE,
2321 1027
                 catch_exceptions=False,
2322 1028
             )
2323
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
2324
-        assert result.stdout.strip(), "expected version output"
2325
-        version_data = parse_version_output(result.stdout)
2326
-        actually_known_formats: dict[str, bool] = {}
2327
-        actually_enabled_extras: set[str] = set()
2328
-        with contextlib.suppress(ModuleNotFoundError):
2329
-            from derivepassphrase.exporter import storeroom, vault_native  # noqa: I001,PLC0415
2330
-
2331
-            actually_known_formats.update({
2332
-                _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED,
2333
-                _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED,
2334
-                _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED,
2335
-            })
2336
-        with contextlib.suppress(ModuleNotFoundError):
2337
-            import cryptography  # noqa: F401,PLC0415
2338
-
2339
-            actually_enabled_extras.add(_types.PEP508Extra.EXPORT)
2340
-        assert not version_data.derivation_schemes
2341
-        assert (
2342
-            version_data.foreign_configuration_formats
2343
-            == actually_known_formats
2344
-        )
2345
-        assert not version_data.subcommands
2346
-        assert not version_data.features
2347
-        assert version_data.extras == actually_enabled_extras
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"
2348 1045
 
2349
-    def test_202d_vault_version_option_output(
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(
2350 1057
         self,
1058
+        notes: str,
2351 1059
     ) -> None:
2352
-        """The version output states supported features.
2353
-
2354
-        The version output is parsed using [`parse_version_output`][].
2355
-        Format examples can be found in
2356
-        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
2357
-        vault command, the output should not contain anything beyond the
2358
-        first paragraph.
2359
-
2360
-        """
1060
+        """Service notes are printed, if they exist."""
1061
+        hypothesis.assume("Error:" not in notes)
2361 1062
         runner = machinery.CliRunner(mix_stderr=False)
2362 1063
         # TODO(the-13th-letter): Rewrite using parenthesized
2363 1064
         # with-statements.
... ...
@@ -2365,81 +1066,45 @@ class TestAllCLI:
2365 1066
         with contextlib.ExitStack() as stack:
2366 1067
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2367 1068
             stack.enter_context(
2368
-                pytest_machinery.isolated_config(
2369
-                    monkeypatch=monkeypatch,
2370
-                    runner=runner,
2371
-                )
2372
-            )
2373
-            result = runner.invoke(
2374
-                cli.derivepassphrase,
2375
-                ["vault", "--version"],
2376
-                catch_exceptions=False,
2377
-            )
2378
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
2379
-        assert result.stdout.strip(), "expected version output"
2380
-        version_data = parse_version_output(result.stdout)
2381
-
2382
-        ssh_key_supported = True
2383
-
2384
-        def react_to_notimplementederror(
2385
-            _exc: BaseException,
2386
-        ) -> None:  # pragma: no cover[unused]
2387
-            nonlocal ssh_key_supported
2388
-            ssh_key_supported = False
2389
-
2390
-        with exceptiongroup.catch({  # noqa: SIM117
2391
-            NotImplementedError: react_to_notimplementederror,
2392
-            Exception: lambda *_args: None,
2393
-        }):
2394
-            with ssh_agent.SSHAgentClient.ensure_agent_subcontext():
2395
-                pass
2396
-        features: dict[str, bool] = {
2397
-            _types.Feature.SSH_KEY: ssh_key_supported,
2398
-        }
2399
-        assert not version_data.derivation_schemes
2400
-        assert not version_data.foreign_configuration_formats
2401
-        assert not version_data.subcommands
2402
-        assert version_data.features == features
2403
-        assert not version_data.extras
2404
-
2405
-
2406
-class TestCLI:
2407
-    """Tests for the `derivepassphrase vault` command-line interface."""
2408
-
2409
-    def test_200_help_output(
2410
-        self,
2411
-    ) -> None:
2412
-        """The `--help` option emits help text."""
2413
-        runner = machinery.CliRunner(mix_stderr=False)
2414
-        # TODO(the-13th-letter): Rewrite using parenthesized
2415
-        # with-statements.
2416
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2417
-        with contextlib.ExitStack() as stack:
2418
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2419
-            stack.enter_context(
2420
-                pytest_machinery.isolated_config(
1069
+                pytest_machinery.isolated_vault_config(
2421 1070
                     monkeypatch=monkeypatch,
2422 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
+                    },
2423 1083
                 )
2424 1084
             )
2425 1085
             result = runner.invoke(
2426 1086
                 cli.derivepassphrase_vault,
2427
-                ["--help"],
2428
-                catch_exceptions=False,
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"
2429 1100
         )
2430
-        assert result.clean_exit(
2431
-            empty_stderr=True, output="Passphrase generation:\n"
2432
-        ), "expected clean exit, and option groups in help text"
2433
-        assert result.clean_exit(
2434
-            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
2435
-        ), "expected clean exit, and option group epilog in help text"
2436 1101
 
2437
-    # TODO(the-13th-letter): Remove this test once
2438
-    # TestAllCLI.test_202_version_option_output no longer xfails.
2439
-    def test_200a_version_output(
1102
+    @Parametrize.VAULT_CHARSET_OPTION
1103
+    def test_210_invalid_argument_range(
2440 1104
         self,
1105
+        option: str,
2441 1106
     ) -> None:
2442
-        """The `--version` option emits version information."""
1107
+        """Requesting invalidly many characters from a class fails."""
2443 1108
         runner = machinery.CliRunner(mix_stderr=False)
2444 1109
         # TODO(the-13th-letter): Rewrite using parenthesized
2445 1110
         # with-statements.
... ...
@@ -2452,26 +1117,26 @@ class TestCLI:
2452 1117
                     runner=runner,
2453 1118
                 )
2454 1119
             )
1120
+            for value in "-42", "invalid":
2455 1121
                 result = runner.invoke(
2456 1122
                     cli.derivepassphrase_vault,
2457
-                ["--version"],
1123
+                    [option, value, "-p", "--", DUMMY_SERVICE],
1124
+                    input=DUMMY_PASSPHRASE,
2458 1125
                     catch_exceptions=False,
2459 1126
                 )
2460
-        assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), (
2461
-            "expected clean exit, and program name in version text"
2462
-        )
2463
-        assert result.clean_exit(empty_stderr=True, output=cli.VERSION), (
2464
-            "expected clean exit, and version in help text"
1127
+                assert result.error_exit(error="Invalid value"), (
1128
+                    "expected error exit and known error message"
2465 1129
                 )
2466 1130
 
2467
-    @Parametrize.CHARSET_NAME
2468
-    def test_201_disable_character_set(
1131
+    @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
1132
+    def test_211_service_needed(
2469 1133
         self,
2470
-        charset_name: str,
1134
+        options: list[str],
1135
+        service: bool | None,
1136
+        input: str | None,
1137
+        check_success: bool,
2471 1138
     ) -> None:
2472
-        """Named character classes can be disabled on the command-line."""
2473
-        option = f"--{charset_name}"
2474
-        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
1139
+        """We require or forbid a service argument, depending on options."""
2475 1140
         runner = machinery.CliRunner(mix_stderr=False)
2476 1141
         # TODO(the-13th-letter): Rewrite using parenthesized
2477 1142
         # with-statements.
... ...
@@ -2479,9 +1144,10 @@ class TestCLI:
2479 1144
         with contextlib.ExitStack() as stack:
2480 1145
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2481 1146
             stack.enter_context(
2482
-                pytest_machinery.isolated_config(
1147
+                pytest_machinery.isolated_vault_config(
2483 1148
                     monkeypatch=monkeypatch,
2484 1149
                     runner=runner,
1150
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2485 1151
                 )
2486 1152
             )
2487 1153
             monkeypatch.setattr(
... ...
@@ -2491,30 +1157,37 @@ class TestCLI:
2491 1157
             )
2492 1158
             result = runner.invoke(
2493 1159
                 cli.derivepassphrase_vault,
2494
-                [option, "0", "-p", "--", DUMMY_SERVICE],
2495
-                input=DUMMY_PASSPHRASE,
1160
+                options if service else [*options, "--", DUMMY_SERVICE],
1161
+                input=input,
2496 1162
                 catch_exceptions=False,
2497 1163
             )
2498
-        assert result.clean_exit(empty_stderr=True), "expected clean exit:"
2499
-        for c in charset:
2500
-            assert c not in result.stdout, (
2501
-                f"derived password contains forbidden character {c!r}"
1164
+            if service is not None:
1165
+                err_msg = (
1166
+                    " requires a SERVICE"
1167
+                    if service
1168
+                    else " does not take a SERVICE argument"
2502 1169
                 )
2503
-
2504
-    def test_202_disable_repetition(
2505
-        self,
2506
-    ) -> None:
2507
-        """Character repetition can be disabled on the command-line."""
2508
-        runner = machinery.CliRunner(mix_stderr=False)
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:
2509 1178
             # TODO(the-13th-letter): Rewrite using parenthesized
2510 1179
             # with-statements.
2511 1180
             # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2512 1181
             with contextlib.ExitStack() as stack:
2513 1182
                 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2514 1183
                 stack.enter_context(
2515
-                pytest_machinery.isolated_config(
1184
+                    pytest_machinery.isolated_vault_config(
2516 1185
                         monkeypatch=monkeypatch,
2517 1186
                         runner=runner,
1187
+                        vault_config={
1188
+                            "global": {"phrase": "abc"},
1189
+                            "services": {},
1190
+                        },
2518 1191
                     )
2519 1192
                 )
2520 1193
                 monkeypatch.setattr(
... ...
@@ -2524,28 +1197,29 @@ class TestCLI:
2524 1197
                 )
2525 1198
                 result = runner.invoke(
2526 1199
                     cli.derivepassphrase_vault,
2527
-                ["--repeat", "0", "-p", "--", DUMMY_SERVICE],
2528
-                input=DUMMY_PASSPHRASE,
1200
+                    [*options, "--", DUMMY_SERVICE] if service else options,
1201
+                    input=input,
2529 1202
                     catch_exceptions=False,
2530 1203
                 )
2531
-        assert result.clean_exit(empty_stderr=True), (
2532
-            "expected clean exit and empty stderr"
2533
-        )
2534
-        passphrase = result.stdout.rstrip("\r\n")
2535
-        for i in range(len(passphrase) - 1):
2536
-            assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
2537
-                f"derived password contains repeated character "
2538
-                f"at position {i}: {result.stdout!r}"
2539
-            )
1204
+            assert result.clean_exit(empty_stderr=True), "expected clean exit"
2540 1205
 
2541
-    @Parametrize.CONFIG_WITH_KEY
2542
-    def test_204a_key_from_config(
1206
+    def test_211a_empty_service_name_causes_warning(
2543 1207
         self,
2544
-        running_ssh_agent: data.RunningSSHAgentInfo,
2545
-        config: _types.VaultConfig,
1208
+        caplog: pytest.LogCaptureFixture,
2546 1209
     ) -> None:
2547
-        """A stored configured SSH key will be used."""
2548
-        del running_ssh_agent
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
+
2549 1223
         runner = machinery.CliRunner(mix_stderr=False)
2550 1224
         # TODO(the-13th-letter): Rewrite using parenthesized
2551 1225
         # with-statements.
... ...
@@ -2556,37 +1230,52 @@ class TestCLI:
2556 1230
                 pytest_machinery.isolated_vault_config(
2557 1231
                     monkeypatch=monkeypatch,
2558 1232
                     runner=runner,
2559
-                    vault_config=config,
1233
+                    vault_config={"services": {}},
2560 1234
                 )
2561 1235
             )
2562 1236
             monkeypatch.setattr(
2563
-                vault.Vault,
2564
-                "phrase_from_key",
2565
-                callables.phrase_from_key,
1237
+                cli_helpers,
1238
+                "prompt_for_passphrase",
1239
+                callables.auto_prompt,
2566 1240
             )
2567 1241
             result = runner.invoke(
2568 1242
                 cli.derivepassphrase_vault,
2569
-                ["--", DUMMY_SERVICE],
1243
+                ["--config", "--length=30", "--", ""],
2570 1244
                 catch_exceptions=False,
2571 1245
             )
2572
-        assert result.clean_exit(empty_stderr=True), (
2573
-            "expected clean exit and empty stderr"
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"
2574 1250
             )
2575
-        assert result.stdout
2576
-        assert (
2577
-            result.stdout.rstrip("\n").encode("UTF-8")
2578
-            != DUMMY_RESULT_PASSPHRASE
2579
-        ), "known false output: phrase-based instead of key-based"
2580
-        assert (
2581
-            result.stdout.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1
2582
-        ), "expected known output"
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"
2583 1271
 
2584
-    def test_204b_key_from_command_line(
1272
+    @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
1273
+    def test_212_incompatible_options(
2585 1274
         self,
2586
-        running_ssh_agent: data.RunningSSHAgentInfo,
1275
+        options: list[str],
1276
+        service: bool | None,
2587 1277
     ) -> None:
2588
-        """An SSH key requested on the command-line will be used."""
2589
-        del running_ssh_agent
1278
+        """Incompatible options are detected."""
2590 1279
         runner = machinery.CliRunner(mix_stderr=False)
2591 1280
         # TODO(the-13th-letter): Rewrite using parenthesized
2592 1281
         # with-statements.
... ...
@@ -2594,50 +1283,28 @@ class TestCLI:
2594 1283
         with contextlib.ExitStack() as stack:
2595 1284
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2596 1285
             stack.enter_context(
2597
-                pytest_machinery.isolated_vault_config(
1286
+                pytest_machinery.isolated_config(
2598 1287
                     monkeypatch=monkeypatch,
2599 1288
                     runner=runner,
2600
-                    vault_config={
2601
-                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
2602
-                    },
2603
-                )
2604 1289
                 )
2605
-            monkeypatch.setattr(
2606
-                cli_helpers,
2607
-                "get_suitable_ssh_keys",
2608
-                callables.suitable_ssh_keys,
2609
-            )
2610
-            monkeypatch.setattr(
2611
-                vault.Vault,
2612
-                "phrase_from_key",
2613
-                callables.phrase_from_key,
2614 1290
             )
2615 1291
             result = runner.invoke(
2616 1292
                 cli.derivepassphrase_vault,
2617
-                ["-k", "--", DUMMY_SERVICE],
2618
-                input="1\n",
1293
+                [*options, "--", DUMMY_SERVICE] if service else options,
1294
+                input=DUMMY_PASSPHRASE,
2619 1295
                 catch_exceptions=False,
2620 1296
             )
2621
-        assert result.clean_exit(), "expected clean exit"
2622
-        assert result.stdout, "expected program output"
2623
-        last_line = result.stdout.splitlines(True)[-1]
2624
-        assert (
2625
-            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
2626
-        ), "known false output: phrase-based instead of key-based"
2627
-        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
2628
-            "expected known output"
1297
+        assert result.error_exit(error="mutually exclusive with "), (
1298
+            "expected error exit and known error message"
2629 1299
         )
2630 1300
 
2631
-    @Parametrize.BASE_CONFIG_WITH_KEY_VARIATIONS
2632
-    @Parametrize.KEY_INDEX
2633
-    def test_204c_key_override_on_command_line(
1301
+    @Parametrize.VALID_TEST_CONFIGS
1302
+    def test_213_import_config_success(
2634 1303
         self,
2635
-        running_ssh_agent: data.RunningSSHAgentInfo,
2636
-        config: dict[str, Any],
2637
-        key_index: int,
1304
+        caplog: pytest.LogCaptureFixture,
1305
+        config: Any,
2638 1306
     ) -> None:
2639
-        """A command-line SSH key will override the configured key."""
2640
-        del running_ssh_agent
1307
+        """Importing a configuration works."""
2641 1308
         runner = machinery.CliRunner(mix_stderr=False)
2642 1309
         # TODO(the-13th-letter): Rewrite using parenthesized
2643 1310
         # with-statements.
... ...
@@ -2648,89 +1315,54 @@ class TestCLI:
2648 1315
                 pytest_machinery.isolated_vault_config(
2649 1316
                     monkeypatch=monkeypatch,
2650 1317
                     runner=runner,
2651
-                    vault_config=config,
1318
+                    vault_config={"services": {}},
2652 1319
                 )
2653 1320
             )
2654
-            monkeypatch.setattr(
2655
-                ssh_agent.SSHAgentClient,
2656
-                "list_keys",
2657
-                callables.list_keys,
2658
-            )
2659
-            monkeypatch.setattr(
2660
-                ssh_agent.SSHAgentClient, "sign", callables.sign
2661
-            )
2662 1321
             result = runner.invoke(
2663 1322
                 cli.derivepassphrase_vault,
2664
-                ["-k", "--", DUMMY_SERVICE],
2665
-                input=f"{key_index}\n",
2666
-            )
2667
-        assert result.clean_exit(), "expected clean exit"
2668
-        assert result.stdout, "expected program output"
2669
-        assert result.stderr, "expected stderr"
2670
-        assert "Error:" not in result.stderr, (
2671
-            "expected no error messages on stderr"
1323
+                ["--import", "-"],
1324
+                input=json.dumps(config),
1325
+                catch_exceptions=False,
2672 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)
2673 1337
 
2674
-    def test_205_service_phrase_if_key_in_global_config(
2675
-        self,
2676
-        running_ssh_agent: data.RunningSSHAgentInfo,
2677
-    ) -> None:
2678
-        """A command-line passphrase will override the configured key."""
2679
-        del running_ssh_agent
2680
-        runner = machinery.CliRunner(mix_stderr=False)
2681
-        # TODO(the-13th-letter): Rewrite using parenthesized
2682
-        # with-statements.
2683
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
2684
-        with contextlib.ExitStack() as stack:
2685
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2686
-            stack.enter_context(
2687
-                pytest_machinery.isolated_vault_config(
2688
-                    monkeypatch=monkeypatch,
2689
-                    runner=runner,
2690
-                    vault_config={
2691
-                        "global": {"key": DUMMY_KEY1_B64},
2692
-                        "services": {
2693
-                            DUMMY_SERVICE: {
2694
-                                "phrase": DUMMY_PASSPHRASE.rstrip("\n"),
2695
-                                **DUMMY_CONFIG_SETTINGS,
2696
-                            }
2697
-                        },
2698
-                    },
2699
-                )
2700
-            )
2701
-            monkeypatch.setattr(
2702
-                ssh_agent.SSHAgentClient,
2703
-                "list_keys",
2704
-                callables.list_keys,
2705
-            )
2706
-            monkeypatch.setattr(
2707
-                ssh_agent.SSHAgentClient, "sign", callables.sign
1338
+    @hypothesis.settings(
1339
+        suppress_health_check=[
1340
+            *hypothesis.settings().suppress_health_check,
1341
+            hypothesis.HealthCheck.function_scoped_fixture,
1342
+        ],
2708 1343
     )
2709
-            result = runner.invoke(
2710
-                cli.derivepassphrase_vault,
2711
-                ["--", DUMMY_SERVICE],
2712
-                catch_exceptions=False,
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
+            ])
2713 1349
         )
2714
-        assert result.clean_exit(), "expected clean exit"
2715
-        assert result.stdout, "expected program output"
2716
-        last_line = result.stdout.splitlines(True)[-1]
2717
-        assert (
2718
-            last_line.rstrip("\n").encode("UTF-8") != DUMMY_RESULT_PASSPHRASE
2719
-        ), "known false output: phrase-based instead of key-based"
2720
-        assert last_line.rstrip("\n").encode("UTF-8") == DUMMY_RESULT_KEY1, (
2721
-            "expected known output"
2722 1350
     )
2723
-
2724
-    @Parametrize.KEY_OVERRIDING_IN_CONFIG
2725
-    def test_206_setting_phrase_thus_overriding_key_in_config(
1351
+    def test_213a_import_config_success(
2726 1352
         self,
2727
-        running_ssh_agent: data.RunningSSHAgentInfo,
2728 1353
         caplog: pytest.LogCaptureFixture,
2729
-        config: _types.VaultConfig,
2730
-        command_line: list[str],
1354
+        conf: data.VaultTestConfig,
2731 1355
     ) -> None:
2732
-        """Configuring a passphrase atop an SSH key works, but warns."""
2733
-        del running_ssh_agent
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()
2734 1366
         runner = machinery.CliRunner(mix_stderr=False)
2735 1367
         # TODO(the-13th-letter): Rewrite using parenthesized
2736 1368
         # with-statements.
... ...
@@ -2741,56 +1373,30 @@ class TestCLI:
2741 1373
                 pytest_machinery.isolated_vault_config(
2742 1374
                     monkeypatch=monkeypatch,
2743 1375
                     runner=runner,
2744
-                    vault_config=config,
2745
-                )
2746
-            )
2747
-            monkeypatch.setattr(
2748
-                ssh_agent.SSHAgentClient,
2749
-                "list_keys",
2750
-                callables.list_keys,
1376
+                    vault_config={"services": {}},
2751 1377
                 )
2752
-            monkeypatch.setattr(
2753
-                ssh_agent.SSHAgentClient, "sign", callables.sign
2754 1378
             )
2755 1379
             result = runner.invoke(
2756 1380
                 cli.derivepassphrase_vault,
2757
-                command_line,
2758
-                input=DUMMY_PASSPHRASE,
1381
+                ["--import", "-"],
1382
+                input=json.dumps(config),
2759 1383
                 catch_exceptions=False,
2760 1384
             )
2761
-        assert result.clean_exit(), "expected clean exit"
2762
-        assert not result.stdout.strip(), "expected no program output"
2763
-        assert result.stderr, "expected known error output"
2764
-        err_lines = result.stderr.splitlines(False)
2765
-        assert err_lines[0].startswith("Passphrase:")
2766
-        assert machinery.warning_emitted(
2767
-            "Setting a service passphrase is ineffective ",
2768
-            caplog.record_tuples,
2769
-        ) or machinery.warning_emitted(
2770
-            "Setting a global passphrase is ineffective ",
2771
-            caplog.record_tuples,
2772
-        ), "expected known warning message"
2773
-        assert all(map(is_warning_line, result.stderr.splitlines(True)))
2774
-        assert all(
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(
2775 1392
             map(is_harmless_config_import_warning, caplog.record_tuples)
2776 1393
         ), "unexpected error output"
1394
+        assert_vault_config_is_indented_and_line_broken(config_txt)
2777 1395
 
2778
-    @hypothesis.given(
2779
-        notes=strategies.text(
2780
-            strategies.characters(
2781
-                min_codepoint=32,
2782
-                max_codepoint=126,
2783
-                include_characters="\n",
2784
-            ),
2785
-            max_size=256,
2786
-        ),
2787
-    )
2788
-    def test_207_service_with_notes_actually_prints_notes(
1396
+    def test_213b_import_bad_config_not_vault_config(
2789 1397
         self,
2790
-        notes: str,
2791 1398
     ) -> None:
2792
-        """Service notes are printed, if they exist."""
2793
-        hypothesis.assume("Error:" not in notes)
1399
+        """Importing an invalid config fails."""
2794 1400
         runner = machinery.CliRunner(mix_stderr=False)
2795 1401
         # TODO(the-13th-letter): Rewrite using parenthesized
2796 1402
         # with-statements.
... ...
@@ -2798,45 +1404,25 @@ class TestCLI:
2798 1404
         with contextlib.ExitStack() as stack:
2799 1405
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2800 1406
             stack.enter_context(
2801
-                pytest_machinery.isolated_vault_config(
1407
+                pytest_machinery.isolated_config(
2802 1408
                     monkeypatch=monkeypatch,
2803 1409
                     runner=runner,
2804
-                    vault_config={
2805
-                        "global": {
2806
-                            "phrase": DUMMY_PASSPHRASE,
2807
-                        },
2808
-                        "services": {
2809
-                            DUMMY_SERVICE: {
2810
-                                "notes": notes,
2811
-                                **DUMMY_CONFIG_SETTINGS,
2812
-                            },
2813
-                        },
2814
-                    },
2815 1410
                 )
2816 1411
             )
2817 1412
             result = runner.invoke(
2818 1413
                 cli.derivepassphrase_vault,
2819
-                ["--", DUMMY_SERVICE],
2820
-            )
2821
-        assert result.clean_exit(), "expected clean exit"
2822
-        assert result.stdout, "expected program output"
2823
-        assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode(
2824
-            "ascii"
2825
-        ), "expected known program output"
2826
-        assert result.stderr or not notes.strip(), "expected stderr"
2827
-        assert "Error:" not in result.stderr, (
2828
-            "expected no error messages on stderr"
1414
+                ["--import", "-"],
1415
+                input="null",
1416
+                catch_exceptions=False,
2829 1417
             )
2830
-        assert result.stderr.strip() == notes.strip(), (
2831
-            "expected known stderr contents"
1418
+        assert result.error_exit(error="Invalid vault config"), (
1419
+            "expected error exit and known error message"
2832 1420
         )
2833 1421
 
2834
-    @Parametrize.VAULT_CHARSET_OPTION
2835
-    def test_210_invalid_argument_range(
1422
+    def test_213c_import_bad_config_not_json_data(
2836 1423
         self,
2837
-        option: str,
2838 1424
     ) -> None:
2839
-        """Requesting invalidly many characters from a class fails."""
1425
+        """Importing an invalid config fails."""
2840 1426
         runner = machinery.CliRunner(mix_stderr=False)
2841 1427
         # TODO(the-13th-letter): Rewrite using parenthesized
2842 1428
         # with-statements.
... ...
@@ -2849,27 +1435,26 @@ class TestCLI:
2849 1435
                     runner=runner,
2850 1436
                 )
2851 1437
             )
2852
-            for value in "-42", "invalid":
2853 1438
             result = runner.invoke(
2854 1439
                 cli.derivepassphrase_vault,
2855
-                    [option, value, "-p", "--", DUMMY_SERVICE],
2856
-                    input=DUMMY_PASSPHRASE,
1440
+                ["--import", "-"],
1441
+                input="This string is not valid JSON.",
2857 1442
                 catch_exceptions=False,
2858 1443
             )
2859
-                assert result.error_exit(error="Invalid value"), (
1444
+        assert result.error_exit(error="cannot decode JSON"), (
2860 1445
             "expected error exit and known error message"
2861 1446
         )
2862 1447
 
2863
-    @Parametrize.OPTION_COMBINATIONS_SERVICE_NEEDED
2864
-    def test_211_service_needed(
1448
+    def test_213d_import_bad_config_not_a_file(
2865 1449
         self,
2866
-        options: list[str],
2867
-        service: bool | None,
2868
-        input: str | None,
2869
-        check_success: bool,
2870 1450
     ) -> None:
2871
-        """We require or forbid a service argument, depending on options."""
1451
+        """Importing an invalid config fails."""
2872 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
+        #
2873 1458
         # TODO(the-13th-letter): Rewrite using parenthesized
2874 1459
         # with-statements.
2875 1460
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2879,34 +1464,33 @@ class TestCLI:
2879 1464
                 pytest_machinery.isolated_vault_config(
2880 1465
                     monkeypatch=monkeypatch,
2881 1466
                     runner=runner,
2882
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
1467
+                    vault_config={"services": {}},
2883 1468
                 )
2884 1469
             )
2885
-            monkeypatch.setattr(
2886
-                cli_helpers,
2887
-                "prompt_for_passphrase",
2888
-                callables.auto_prompt,
1470
+            cli_helpers.config_filename(subsystem="vault").write_text(
1471
+                "This string is not valid JSON.\n", encoding="UTF-8"
2889 1472
             )
1473
+            dname = cli_helpers.config_filename(subsystem=None)
2890 1474
             result = runner.invoke(
2891 1475
                 cli.derivepassphrase_vault,
2892
-                options if service else [*options, "--", DUMMY_SERVICE],
2893
-                input=input,
1476
+                ["--import", os.fsdecode(dname)],
2894 1477
                 catch_exceptions=False,
2895 1478
             )
2896
-            if service is not None:
2897
-                err_msg = (
2898
-                    " requires a SERVICE"
2899
-                    if service
2900
-                    else " does not take a SERVICE argument"
2901
-                )
2902
-                assert result.error_exit(error=err_msg), (
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)), (
2903 1483
             "expected error exit and known error message"
2904 1484
         )
2905
-            else:
2906
-                assert result.clean_exit(empty_stderr=True), (
2907
-                    "expected clean exit"
2908
-                )
2909
-        if check_success:
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)
2910 1494
         # TODO(the-13th-letter): Rewrite using parenthesized
2911 1495
         # with-statements.
2912 1496
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2916,42 +1500,36 @@ class TestCLI:
2916 1500
                 pytest_machinery.isolated_vault_config(
2917 1501
                     monkeypatch=monkeypatch,
2918 1502
                     runner=runner,
2919
-                        vault_config={
2920
-                            "global": {"phrase": "abc"},
2921
-                            "services": {},
2922
-                        },
1503
+                    vault_config=config,
2923 1504
                 )
2924 1505
             )
2925
-                monkeypatch.setattr(
2926
-                    cli_helpers,
2927
-                    "prompt_for_passphrase",
2928
-                    callables.auto_prompt,
2929
-                )
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)
2930 1511
             result = runner.invoke(
2931 1512
                 cli.derivepassphrase_vault,
2932
-                    [*options, "--", DUMMY_SERVICE] if service else options,
2933
-                    input=input,
1513
+                ["--export", "-"],
2934 1514
                 catch_exceptions=False,
2935 1515
             )
2936
-            assert result.clean_exit(empty_stderr=True), "expected clean exit"
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)
2937 1526
 
2938
-    def test_211a_empty_service_name_causes_warning(
1527
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1528
+    def test_214a_export_settings_no_stored_settings(
2939 1529
         self,
2940
-        caplog: pytest.LogCaptureFixture,
1530
+        export_options: list[str],
2941 1531
     ) -> None:
2942
-        """Using an empty service name (where permissible) warns.
2943
-
2944
-        Only the `--config` option can optionally take a service name.
2945
-
2946
-        """
2947
-
2948
-        def is_expected_warning(record: tuple[str, int, str]) -> bool:
2949
-            return is_harmless_config_import_warning(
2950
-                record
2951
-            ) or machinery.warning_emitted(
2952
-                "An empty SERVICE is not supported by vault(1)", [record]
2953
-            )
2954
-
1532
+        """Exporting the default, empty config works."""
2955 1533
         runner = machinery.CliRunner(mix_stderr=False)
2956 1534
         # TODO(the-13th-letter): Rewrite using parenthesized
2957 1535
         # with-statements.
... ...
@@ -2959,55 +1537,31 @@ class TestCLI:
2959 1537
         with contextlib.ExitStack() as stack:
2960 1538
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
2961 1539
             stack.enter_context(
2962
-                pytest_machinery.isolated_vault_config(
1540
+                pytest_machinery.isolated_config(
2963 1541
                     monkeypatch=monkeypatch,
2964 1542
                     runner=runner,
2965
-                    vault_config={"services": {}},
2966
-                )
2967
-            )
2968
-            monkeypatch.setattr(
2969
-                cli_helpers,
2970
-                "prompt_for_passphrase",
2971
-                callables.auto_prompt,
2972 1543
                 )
2973
-            result = runner.invoke(
2974
-                cli.derivepassphrase_vault,
2975
-                ["--config", "--length=30", "--", ""],
2976
-                catch_exceptions=False,
2977 1544
             )
2978
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
2979
-            assert result.stderr is not None, "expected known error output"
2980
-            assert all(map(is_expected_warning, caplog.record_tuples)), (
2981
-                "expected known error output"
1545
+            cli_helpers.config_filename(subsystem="vault").unlink(
1546
+                missing_ok=True
2982 1547
             )
2983
-            assert cli_helpers.load_config() == {
2984
-                "global": {"length": 30},
2985
-                "services": {},
2986
-            }, "requested configuration change was not applied"
2987
-            caplog.clear()
2988 1548
             result = runner.invoke(
2989
-                cli.derivepassphrase_vault,
2990
-                ["--import", "-"],
2991
-                input=json.dumps({"services": {"": {"length": 40}}}),
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],
2992 1555
                 catch_exceptions=False,
2993 1556
             )
2994
-            assert result.clean_exit(empty_stderr=False), "expected clean exit"
2995
-            assert result.stderr is not None, "expected known error output"
2996
-            assert all(map(is_expected_warning, caplog.record_tuples)), (
2997
-                "expected known error output"
2998
-            )
2999
-            assert cli_helpers.load_config() == {
3000
-                "global": {"length": 30},
3001
-                "services": {"": {"length": 40}},
3002
-            }, "requested configuration change was not applied"
1557
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
3003 1558
 
3004
-    @Parametrize.OPTION_COMBINATIONS_INCOMPATIBLE
3005
-    def test_212_incompatible_options(
1559
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1560
+    def test_214b_export_settings_bad_stored_config(
3006 1561
         self,
3007
-        options: list[str],
3008
-        service: bool | None,
1562
+        export_options: list[str],
3009 1563
     ) -> None:
3010
-        """Incompatible options are detected."""
1564
+        """Exporting an invalid config fails."""
3011 1565
         runner = machinery.CliRunner(mix_stderr=False)
3012 1566
         # TODO(the-13th-letter): Rewrite using parenthesized
3013 1567
         # with-statements.
... ...
@@ -3015,28 +1569,28 @@ class TestCLI:
3015 1569
         with contextlib.ExitStack() as stack:
3016 1570
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3017 1571
             stack.enter_context(
3018
-                pytest_machinery.isolated_config(
1572
+                pytest_machinery.isolated_vault_config(
3019 1573
                     monkeypatch=monkeypatch,
3020 1574
                     runner=runner,
1575
+                    vault_config={},
3021 1576
                 )
3022 1577
             )
3023 1578
             result = runner.invoke(
3024 1579
                 cli.derivepassphrase_vault,
3025
-                [*options, "--", DUMMY_SERVICE] if service else options,
3026
-                input=DUMMY_PASSPHRASE,
1580
+                ["--export", "-", *export_options],
1581
+                input="null",
3027 1582
                 catch_exceptions=False,
3028 1583
             )
3029
-        assert result.error_exit(error="mutually exclusive with "), (
1584
+        assert result.error_exit(error="Cannot load vault settings:"), (
3030 1585
             "expected error exit and known error message"
3031 1586
         )
3032 1587
 
3033
-    @Parametrize.VALID_TEST_CONFIGS
3034
-    def test_213_import_config_success(
1588
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1589
+    def test_214c_export_settings_not_a_file(
3035 1590
         self,
3036
-        caplog: pytest.LogCaptureFixture,
3037
-        config: Any,
1591
+        export_options: list[str],
3038 1592
     ) -> None:
3039
-        """Importing a configuration works."""
1593
+        """Exporting an invalid config fails."""
3040 1594
         runner = machinery.CliRunner(mix_stderr=False)
3041 1595
         # TODO(the-13th-letter): Rewrite using parenthesized
3042 1596
         # with-statements.
... ...
@@ -3044,57 +1598,30 @@ class TestCLI:
3044 1598
         with contextlib.ExitStack() as stack:
3045 1599
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3046 1600
             stack.enter_context(
3047
-                pytest_machinery.isolated_vault_config(
1601
+                pytest_machinery.isolated_config(
3048 1602
                     monkeypatch=monkeypatch,
3049 1603
                     runner=runner,
3050
-                    vault_config={"services": {}},
3051 1604
                 )
3052 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)
3053 1609
             result = runner.invoke(
3054 1610
                 cli.derivepassphrase_vault,
3055
-                ["--import", "-"],
3056
-                input=json.dumps(config),
1611
+                ["--export", "-", *export_options],
1612
+                input="null",
3057 1613
                 catch_exceptions=False,
3058 1614
             )
3059
-            config_txt = cli_helpers.config_filename(
3060
-                subsystem="vault"
3061
-            ).read_text(encoding="UTF-8")
3062
-            config2 = json.loads(config_txt)
3063
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
3064
-        assert config2 == config, "config not imported correctly"
3065
-        assert not result.stderr or all(  # pragma: no branch
3066
-            map(is_harmless_config_import_warning, caplog.record_tuples)
3067
-        ), "unexpected error output"
3068
-        assert_vault_config_is_indented_and_line_broken(config_txt)
3069
-
3070
-    @hypothesis.settings(
3071
-        suppress_health_check=[
3072
-            *hypothesis.settings().suppress_health_check,
3073
-            hypothesis.HealthCheck.function_scoped_fixture,
3074
-        ],
3075
-    )
3076
-    @hypothesis.given(
3077
-        conf=hypothesis_machinery.smudged_vault_test_config(
3078
-            strategies.sampled_from([
3079
-                conf for conf in data.TEST_CONFIGS if conf.is_valid()
3080
-            ])
3081
-        )
1615
+        assert result.error_exit(error="Cannot load vault settings:"), (
1616
+            "expected error exit and known error message"
3082 1617
         )
3083
-    def test_213a_import_config_success(
1618
+
1619
+    @Parametrize.EXPORT_FORMAT_OPTIONS
1620
+    def test_214d_export_settings_target_not_a_file(
3084 1621
         self,
3085
-        caplog: pytest.LogCaptureFixture,
3086
-        conf: data.VaultTestConfig,
1622
+        export_options: list[str],
3087 1623
     ) -> None:
3088
-        """Importing a smudged configuration works.
3089
-
3090
-        Tested via hypothesis.
3091
-
3092
-        """
3093
-        config = conf.config
3094
-        config2 = copy.deepcopy(config)
3095
-        _types.clean_up_falsy_vault_config_values(config2)
3096
-        # Reset caplog between hypothesis runs.
3097
-        caplog.clear()
1624
+        """Exporting an invalid config fails."""
3098 1625
         runner = machinery.CliRunner(mix_stderr=False)
3099 1626
         # TODO(the-13th-letter): Rewrite using parenthesized
3100 1627
         # with-statements.
... ...
@@ -3102,33 +1629,29 @@ class TestCLI:
3102 1629
         with contextlib.ExitStack() as stack:
3103 1630
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3104 1631
             stack.enter_context(
3105
-                pytest_machinery.isolated_vault_config(
1632
+                pytest_machinery.isolated_config(
3106 1633
                     monkeypatch=monkeypatch,
3107 1634
                     runner=runner,
3108
-                    vault_config={"services": {}},
3109 1635
                 )
3110 1636
             )
1637
+            dname = cli_helpers.config_filename(subsystem=None)
3111 1638
             result = runner.invoke(
3112 1639
                 cli.derivepassphrase_vault,
3113
-                ["--import", "-"],
3114
-                input=json.dumps(config),
1640
+                ["--export", os.fsdecode(dname), *export_options],
1641
+                input="null",
3115 1642
                 catch_exceptions=False,
3116 1643
             )
3117
-            config_txt = cli_helpers.config_filename(
3118
-                subsystem="vault"
3119
-            ).read_text(encoding="UTF-8")
3120
-            config3 = json.loads(config_txt)
3121
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
3122
-        assert config3 == config2, "config not imported correctly"
3123
-        assert not result.stderr or all(
3124
-            map(is_harmless_config_import_warning, caplog.record_tuples)
3125
-        ), "unexpected error output"
3126
-        assert_vault_config_is_indented_and_line_broken(config_txt)
1644
+        assert result.error_exit(error="Cannot export vault settings:"), (
1645
+            "expected error exit and known error message"
1646
+        )
3127 1647
 
3128
-    def test_213b_import_bad_config_not_vault_config(
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(
3129 1651
         self,
1652
+        export_options: list[str],
3130 1653
     ) -> None:
3131
-        """Importing an invalid config fails."""
1654
+        """Exporting an invalid config fails."""
3132 1655
         runner = machinery.CliRunner(mix_stderr=False)
3133 1656
         # TODO(the-13th-letter): Rewrite using parenthesized
3134 1657
         # with-statements.
... ...
@@ -3141,1240 +1664,105 @@ class TestCLI:
3141 1664
                     runner=runner,
3142 1665
                 )
3143 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")
3144 1671
             result = runner.invoke(
3145 1672
                 cli.derivepassphrase_vault,
3146
-                ["--import", "-"],
1673
+                ["--export", "-", *export_options],
3147 1674
                 input="null",
3148 1675
                 catch_exceptions=False,
3149 1676
             )
3150
-        assert result.error_exit(error="Invalid vault config"), (
1677
+        assert result.error_exit(
1678
+            error="Cannot load vault settings:"
1679
+        ) or result.error_exit(error="Cannot load user config:"), (
3151 1680
             "expected error exit and known error message"
3152 1681
         )
3153 1682
 
3154
-    def test_213c_import_bad_config_not_json_data(
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(
3155 1694
         self,
1695
+        notes_placement: Literal["before", "after"],
1696
+        placement_args: list[str],
1697
+        notes: str,
3156 1698
     ) -> None:
3157
-        """Importing an invalid config fails."""
3158
-        runner = machinery.CliRunner(mix_stderr=False)
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)
3159 1714
         # TODO(the-13th-letter): Rewrite using parenthesized
3160 1715
         # with-statements.
3161 1716
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3162 1717
         with contextlib.ExitStack() as stack:
3163 1718
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3164 1719
             stack.enter_context(
3165
-                pytest_machinery.isolated_config(
1720
+                pytest_machinery.isolated_vault_config(
3166 1721
                     monkeypatch=monkeypatch,
3167 1722
                     runner=runner,
1723
+                    vault_config=vault_config,
3168 1724
                 )
3169 1725
             )
3170 1726
             result = runner.invoke(
3171 1727
                 cli.derivepassphrase_vault,
3172
-                ["--import", "-"],
3173
-                input="This string is not valid JSON.",
1728
+                [*placement_args, "--", DUMMY_SERVICE],
3174 1729
                 catch_exceptions=False,
3175 1730
             )
3176
-        assert result.error_exit(error="cannot decode JSON"), (
3177
-            "expected error exit and known error message"
3178
-        )
1731
+            assert result.clean_exit(output=expected), "expected clean exit"
3179 1732
 
3180
-    def test_213d_import_bad_config_not_a_file(
3181
-        self,
3182
-    ) -> None:
3183
-        """Importing an invalid config fails."""
3184
-        runner = machinery.CliRunner(mix_stderr=False)
3185
-        # `isolated_vault_config` ensures the configuration is valid
3186
-        # JSON.  So, to pass an actual broken configuration, we must
3187
-        # open the configuration file ourselves afterwards, inside the
3188
-        # context.
3189
-        #
3190
-        # TODO(the-13th-letter): Rewrite using parenthesized
3191
-        # with-statements.
3192
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3193
-        with contextlib.ExitStack() as stack:
3194
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3195
-            stack.enter_context(
3196
-                pytest_machinery.isolated_vault_config(
3197
-                    monkeypatch=monkeypatch,
3198
-                    runner=runner,
3199
-                    vault_config={"services": {}},
3200
-                )
3201
-            )
3202
-            cli_helpers.config_filename(subsystem="vault").write_text(
3203
-                "This string is not valid JSON.\n", encoding="UTF-8"
3204
-            )
3205
-            dname = cli_helpers.config_filename(subsystem=None)
3206
-            result = runner.invoke(
3207
-                cli.derivepassphrase_vault,
3208
-                ["--import", os.fsdecode(dname)],
3209
-                catch_exceptions=False,
3210
-            )
3211
-        # The Annoying OS uses EACCES, other OSes use EISDIR.
3212
-        assert result.error_exit(
3213
-            error=os.strerror(errno.EISDIR)
3214
-        ) or result.error_exit(error=os.strerror(errno.EACCES)), (
3215
-            "expected error exit and known error message"
3216
-        )
3217
-
3218
-    @Parametrize.VALID_TEST_CONFIGS
3219
-    def test_214_export_config_success(
3220
-        self,
3221
-        caplog: pytest.LogCaptureFixture,
3222
-        config: Any,
3223
-    ) -> None:
3224
-        """Exporting a configuration works."""
3225
-        runner = machinery.CliRunner(mix_stderr=False)
3226
-        # TODO(the-13th-letter): Rewrite using parenthesized
3227
-        # with-statements.
3228
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3229
-        with contextlib.ExitStack() as stack:
3230
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3231
-            stack.enter_context(
3232
-                pytest_machinery.isolated_vault_config(
3233
-                    monkeypatch=monkeypatch,
3234
-                    runner=runner,
3235
-                    vault_config=config,
3236
-                )
3237
-            )
3238
-            with cli_helpers.config_filename(subsystem="vault").open(
3239
-                "w", encoding="UTF-8"
3240
-            ) as outfile:
3241
-                # Ensure the config is written on one line.
3242
-                json.dump(config, outfile, indent=None)
3243
-            result = runner.invoke(
3244
-                cli.derivepassphrase_vault,
3245
-                ["--export", "-"],
3246
-                catch_exceptions=False,
3247
-            )
3248
-            with cli_helpers.config_filename(subsystem="vault").open(
3249
-                encoding="UTF-8"
3250
-            ) as infile:
3251
-                config2 = json.load(infile)
3252
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
3253
-        assert config2 == config, "config not imported correctly"
3254
-        assert not result.stderr or all(  # pragma: no branch
3255
-            map(is_harmless_config_import_warning, caplog.record_tuples)
3256
-        ), "unexpected error output"
3257
-        assert_vault_config_is_indented_and_line_broken(result.stdout)
3258
-
3259
-    @Parametrize.EXPORT_FORMAT_OPTIONS
3260
-    def test_214a_export_settings_no_stored_settings(
3261
-        self,
3262
-        export_options: list[str],
3263
-    ) -> None:
3264
-        """Exporting the default, empty config works."""
3265
-        runner = machinery.CliRunner(mix_stderr=False)
3266
-        # TODO(the-13th-letter): Rewrite using parenthesized
3267
-        # with-statements.
3268
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3269
-        with contextlib.ExitStack() as stack:
3270
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3271
-            stack.enter_context(
3272
-                pytest_machinery.isolated_config(
3273
-                    monkeypatch=monkeypatch,
3274
-                    runner=runner,
3275
-                )
3276
-            )
3277
-            cli_helpers.config_filename(subsystem="vault").unlink(
3278
-                missing_ok=True
3279
-            )
3280
-            result = runner.invoke(
3281
-                # Test parent context navigation by not calling
3282
-                # `cli.derivepassphrase_vault` directly.  Used e.g. in
3283
-                # the `--export-as=sh` section to autoconstruct the
3284
-                # program name correctly.
3285
-                cli.derivepassphrase,
3286
-                ["vault", "--export", "-", *export_options],
3287
-                catch_exceptions=False,
3288
-            )
3289
-        assert result.clean_exit(empty_stderr=True), "expected clean exit"
3290
-
3291
-    @Parametrize.EXPORT_FORMAT_OPTIONS
3292
-    def test_214b_export_settings_bad_stored_config(
3293
-        self,
3294
-        export_options: list[str],
3295
-    ) -> None:
3296
-        """Exporting an invalid config fails."""
3297
-        runner = machinery.CliRunner(mix_stderr=False)
3298
-        # TODO(the-13th-letter): Rewrite using parenthesized
3299
-        # with-statements.
3300
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3301
-        with contextlib.ExitStack() as stack:
3302
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3303
-            stack.enter_context(
3304
-                pytest_machinery.isolated_vault_config(
3305
-                    monkeypatch=monkeypatch,
3306
-                    runner=runner,
3307
-                    vault_config={},
3308
-                )
3309
-            )
3310
-            result = runner.invoke(
3311
-                cli.derivepassphrase_vault,
3312
-                ["--export", "-", *export_options],
3313
-                input="null",
3314
-                catch_exceptions=False,
3315
-            )
3316
-        assert result.error_exit(error="Cannot load vault settings:"), (
3317
-            "expected error exit and known error message"
3318
-        )
3319
-
3320
-    @Parametrize.EXPORT_FORMAT_OPTIONS
3321
-    def test_214c_export_settings_not_a_file(
3322
-        self,
3323
-        export_options: list[str],
3324
-    ) -> None:
3325
-        """Exporting an invalid config fails."""
3326
-        runner = machinery.CliRunner(mix_stderr=False)
3327
-        # TODO(the-13th-letter): Rewrite using parenthesized
3328
-        # with-statements.
3329
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3330
-        with contextlib.ExitStack() as stack:
3331
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3332
-            stack.enter_context(
3333
-                pytest_machinery.isolated_config(
3334
-                    monkeypatch=monkeypatch,
3335
-                    runner=runner,
3336
-                )
3337
-            )
3338
-            config_file = cli_helpers.config_filename(subsystem="vault")
3339
-            config_file.unlink(missing_ok=True)
3340
-            config_file.mkdir(parents=True, exist_ok=True)
3341
-            result = runner.invoke(
3342
-                cli.derivepassphrase_vault,
3343
-                ["--export", "-", *export_options],
3344
-                input="null",
3345
-                catch_exceptions=False,
3346
-            )
3347
-        assert result.error_exit(error="Cannot load vault settings:"), (
3348
-            "expected error exit and known error message"
3349
-        )
3350
-
3351
-    @Parametrize.EXPORT_FORMAT_OPTIONS
3352
-    def test_214d_export_settings_target_not_a_file(
3353
-        self,
3354
-        export_options: list[str],
3355
-    ) -> None:
3356
-        """Exporting an invalid config fails."""
3357
-        runner = machinery.CliRunner(mix_stderr=False)
3358
-        # TODO(the-13th-letter): Rewrite using parenthesized
3359
-        # with-statements.
3360
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3361
-        with contextlib.ExitStack() as stack:
3362
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3363
-            stack.enter_context(
3364
-                pytest_machinery.isolated_config(
3365
-                    monkeypatch=monkeypatch,
3366
-                    runner=runner,
3367
-                )
3368
-            )
3369
-            dname = cli_helpers.config_filename(subsystem=None)
3370
-            result = runner.invoke(
3371
-                cli.derivepassphrase_vault,
3372
-                ["--export", os.fsdecode(dname), *export_options],
3373
-                input="null",
3374
-                catch_exceptions=False,
3375
-            )
3376
-        assert result.error_exit(error="Cannot export vault settings:"), (
3377
-            "expected error exit and known error message"
3378
-        )
3379
-
3380
-    @pytest_machinery.skip_if_on_the_annoying_os
3381
-    @Parametrize.EXPORT_FORMAT_OPTIONS
3382
-    def test_214e_export_settings_settings_directory_not_a_directory(
3383
-        self,
3384
-        export_options: list[str],
3385
-    ) -> None:
3386
-        """Exporting an invalid config fails."""
3387
-        runner = machinery.CliRunner(mix_stderr=False)
3388
-        # TODO(the-13th-letter): Rewrite using parenthesized
3389
-        # with-statements.
3390
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3391
-        with contextlib.ExitStack() as stack:
3392
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3393
-            stack.enter_context(
3394
-                pytest_machinery.isolated_config(
3395
-                    monkeypatch=monkeypatch,
3396
-                    runner=runner,
3397
-                )
3398
-            )
3399
-            config_dir = cli_helpers.config_filename(subsystem=None)
3400
-            with contextlib.suppress(FileNotFoundError):
3401
-                shutil.rmtree(config_dir)
3402
-            config_dir.write_text("Obstruction!!\n")
3403
-            result = runner.invoke(
3404
-                cli.derivepassphrase_vault,
3405
-                ["--export", "-", *export_options],
3406
-                input="null",
3407
-                catch_exceptions=False,
3408
-            )
3409
-        assert result.error_exit(
3410
-            error="Cannot load vault settings:"
3411
-        ) or result.error_exit(error="Cannot load user config:"), (
3412
-            "expected error exit and known error message"
3413
-        )
3414
-
3415
-    @Parametrize.NOTES_PLACEMENT
3416
-    @hypothesis.given(
3417
-        notes=strategies.text(
3418
-            strategies.characters(
3419
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
3420
-            ),
3421
-            min_size=1,
3422
-            max_size=512,
3423
-        ).filter(str.strip),
3424
-    )
3425
-    def test_215_notes_placement(
3426
-        self,
3427
-        notes_placement: Literal["before", "after"],
3428
-        placement_args: list[str],
3429
-        notes: str,
3430
-    ) -> None:
3431
-        notes = notes.strip()
3432
-        maybe_notes = {"notes": notes} if notes else {}
3433
-        vault_config = {
3434
-            "global": {"phrase": DUMMY_PASSPHRASE},
3435
-            "services": {
3436
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
3437
-            },
3438
-        }
3439
-        result_phrase = DUMMY_RESULT_PASSPHRASE.decode("ascii")
3440
-        expected = (
3441
-            f"{notes}\n\n{result_phrase}\n"
3442
-            if notes_placement == "before"
3443
-            else f"{result_phrase}\n\n{notes}\n\n"
3444
-        )
3445
-        runner = machinery.CliRunner(mix_stderr=True)
3446
-        # TODO(the-13th-letter): Rewrite using parenthesized
3447
-        # with-statements.
3448
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3449
-        with contextlib.ExitStack() as stack:
3450
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3451
-            stack.enter_context(
3452
-                pytest_machinery.isolated_vault_config(
3453
-                    monkeypatch=monkeypatch,
3454
-                    runner=runner,
3455
-                    vault_config=vault_config,
3456
-                )
3457
-            )
3458
-            result = runner.invoke(
3459
-                cli.derivepassphrase_vault,
3460
-                [*placement_args, "--", DUMMY_SERVICE],
3461
-                catch_exceptions=False,
3462
-            )
3463
-            assert result.clean_exit(output=expected), "expected clean exit"
3464
-
3465
-    @Parametrize.MODERN_EDITOR_INTERFACE
3466
-    @hypothesis.settings(
3467
-        suppress_health_check=[
3468
-            *hypothesis.settings().suppress_health_check,
3469
-            hypothesis.HealthCheck.function_scoped_fixture,
3470
-        ],
3471
-    )
3472
-    @hypothesis.given(
3473
-        notes=strategies.text(
3474
-            strategies.characters(
3475
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
3476
-            ),
3477
-            min_size=1,
3478
-            max_size=512,
3479
-        ).filter(str.strip),
3480
-    )
3481
-    def test_220_edit_notes_successfully(
3482
-        self,
3483
-        caplog: pytest.LogCaptureFixture,
3484
-        modern_editor_interface: bool,
3485
-        notes: str,
3486
-    ) -> None:
3487
-        """Editing notes works."""
3488
-        marker = cli_messages.TranslatedString(
3489
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
3490
-        )
3491
-        edit_result = f"""
3492
-
3493
-{marker}
3494
-{notes}
3495
-"""
3496
-        # Reset caplog between hypothesis runs.
3497
-        caplog.clear()
3498
-        runner = machinery.CliRunner(mix_stderr=False)
3499
-        # TODO(the-13th-letter): Rewrite using parenthesized
3500
-        # with-statements.
3501
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3502
-        with contextlib.ExitStack() as stack:
3503
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3504
-            stack.enter_context(
3505
-                pytest_machinery.isolated_vault_config(
3506
-                    monkeypatch=monkeypatch,
3507
-                    runner=runner,
3508
-                    vault_config={
3509
-                        "global": {"phrase": "abc"},
3510
-                        "services": {"sv": {"notes": "Contents go here"}},
3511
-                    },
3512
-                )
3513
-            )
3514
-            notes_backup_file = cli_helpers.config_filename(
3515
-                subsystem="notes backup"
3516
-            )
3517
-            notes_backup_file.write_text(
3518
-                "These backup notes are left over from the previous session.",
3519
-                encoding="UTF-8",
3520
-            )
3521
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: edit_result)
3522
-            result = runner.invoke(
3523
-                cli.derivepassphrase_vault,
3524
-                [
3525
-                    "--config",
3526
-                    "--notes",
3527
-                    "--modern-editor-interface"
3528
-                    if modern_editor_interface
3529
-                    else "--vault-legacy-editor-interface",
3530
-                    "--",
3531
-                    "sv",
3532
-                ],
3533
-                catch_exceptions=False,
3534
-            )
3535
-            assert result.clean_exit(), "expected clean exit"
3536
-            assert all(map(is_warning_line, result.stderr.splitlines(True)))
3537
-            assert modern_editor_interface or machinery.warning_emitted(
3538
-                "A backup copy of the old notes was saved",
3539
-                caplog.record_tuples,
3540
-            ), "expected known warning message in stderr"
3541
-            assert (
3542
-                modern_editor_interface
3543
-                or notes_backup_file.read_text(encoding="UTF-8")
3544
-                == "Contents go here"
3545
-            )
3546
-            with cli_helpers.config_filename(subsystem="vault").open(
3547
-                encoding="UTF-8"
3548
-            ) as infile:
3549
-                config = json.load(infile)
3550
-            assert config == {
3551
-                "global": {"phrase": "abc"},
3552
-                "services": {
3553
-                    "sv": {
3554
-                        "notes": notes.strip()
3555
-                        if modern_editor_interface
3556
-                        else edit_result.strip()
3557
-                    }
3558
-                },
3559
-            }
3560
-
3561
-    @Parametrize.NOOP_EDIT_FUNCS
3562
-    @hypothesis.given(
3563
-        notes=strategies.text(
3564
-            strategies.characters(
3565
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
3566
-            ),
3567
-            min_size=1,
3568
-            max_size=512,
3569
-        ).filter(str.strip),
3570
-    )
3571
-    def test_221_edit_notes_noop(
3572
-        self,
3573
-        edit_func_name: Literal["empty", "space"],
3574
-        modern_editor_interface: bool,
3575
-        notes: str,
3576
-    ) -> None:
3577
-        """Abandoning edited notes works."""
3578
-
3579
-        def empty(text: str, *_args: Any, **_kwargs: Any) -> str:
3580
-            del text
3581
-            return ""
3582
-
3583
-        def space(text: str, *_args: Any, **_kwargs: Any) -> str:
3584
-            del text
3585
-            return "       " + notes.strip() + "\n\n\n\n\n\n"
3586
-
3587
-        edit_funcs = {"empty": empty, "space": space}
3588
-        runner = machinery.CliRunner(mix_stderr=False)
3589
-        # TODO(the-13th-letter): Rewrite using parenthesized
3590
-        # with-statements.
3591
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3592
-        with contextlib.ExitStack() as stack:
3593
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3594
-            stack.enter_context(
3595
-                pytest_machinery.isolated_vault_config(
3596
-                    monkeypatch=monkeypatch,
3597
-                    runner=runner,
3598
-                    vault_config={
3599
-                        "global": {"phrase": "abc"},
3600
-                        "services": {"sv": {"notes": notes.strip()}},
3601
-                    },
3602
-                )
3603
-            )
3604
-            notes_backup_file = cli_helpers.config_filename(
3605
-                subsystem="notes backup"
3606
-            )
3607
-            notes_backup_file.write_text(
3608
-                "These backup notes are left over from the previous session.",
3609
-                encoding="UTF-8",
3610
-            )
3611
-            monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name])
3612
-            result = runner.invoke(
3613
-                cli.derivepassphrase_vault,
3614
-                [
3615
-                    "--config",
3616
-                    "--notes",
3617
-                    "--modern-editor-interface"
3618
-                    if modern_editor_interface
3619
-                    else "--vault-legacy-editor-interface",
3620
-                    "--",
3621
-                    "sv",
3622
-                ],
3623
-                catch_exceptions=False,
3624
-            )
3625
-            assert result.clean_exit(empty_stderr=True) or result.error_exit(
3626
-                error="the user aborted the request"
3627
-            ), "expected clean exit"
3628
-            assert (
3629
-                modern_editor_interface
3630
-                or notes_backup_file.read_text(encoding="UTF-8")
3631
-                == "These backup notes are left over from the previous session."
3632
-            )
3633
-            with cli_helpers.config_filename(subsystem="vault").open(
3634
-                encoding="UTF-8"
3635
-            ) as infile:
3636
-                config = json.load(infile)
3637
-            assert config == {
3638
-                "global": {"phrase": "abc"},
3639
-                "services": {"sv": {"notes": notes.strip()}},
3640
-            }
3641
-
3642
-    # TODO(the-13th-letter): Keep this behavior or not, with or without
3643
-    # warning?
3644
-    @Parametrize.MODERN_EDITOR_INTERFACE
3645
-    @hypothesis.settings(
3646
-        suppress_health_check=[
3647
-            *hypothesis.settings().suppress_health_check,
3648
-            hypothesis.HealthCheck.function_scoped_fixture,
3649
-        ],
3650
-    )
3651
-    @hypothesis.given(
3652
-        notes=strategies.text(
3653
-            strategies.characters(
3654
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
3655
-            ),
3656
-            min_size=1,
3657
-            max_size=512,
3658
-        ).filter(str.strip),
3659
-    )
3660
-    def test_222_edit_notes_marker_removed(
3661
-        self,
3662
-        caplog: pytest.LogCaptureFixture,
3663
-        modern_editor_interface: bool,
3664
-        notes: str,
3665
-    ) -> None:
3666
-        """Removing the notes marker still saves the notes.
3667
-
3668
-        TODO: Keep this behavior or not, with or without warning?
3669
-
3670
-        """
3671
-        notes_marker = cli_messages.TranslatedString(
3672
-            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
3673
-        )
3674
-        hypothesis.assume(str(notes_marker) not in notes.strip())
3675
-        # Reset caplog between hypothesis runs.
3676
-        caplog.clear()
3677
-        runner = machinery.CliRunner(mix_stderr=False)
3678
-        # TODO(the-13th-letter): Rewrite using parenthesized
3679
-        # with-statements.
3680
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3681
-        with contextlib.ExitStack() as stack:
3682
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3683
-            stack.enter_context(
3684
-                pytest_machinery.isolated_vault_config(
3685
-                    monkeypatch=monkeypatch,
3686
-                    runner=runner,
3687
-                    vault_config={
3688
-                        "global": {"phrase": "abc"},
3689
-                        "services": {"sv": {"notes": "Contents go here"}},
3690
-                    },
3691
-                )
3692
-            )
3693
-            notes_backup_file = cli_helpers.config_filename(
3694
-                subsystem="notes backup"
3695
-            )
3696
-            notes_backup_file.write_text(
3697
-                "These backup notes are left over from the previous session.",
3698
-                encoding="UTF-8",
3699
-            )
3700
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: notes)
3701
-            result = runner.invoke(
3702
-                cli.derivepassphrase_vault,
3703
-                [
3704
-                    "--config",
3705
-                    "--notes",
3706
-                    "--modern-editor-interface"
3707
-                    if modern_editor_interface
3708
-                    else "--vault-legacy-editor-interface",
3709
-                    "--",
3710
-                    "sv",
3711
-                ],
3712
-                catch_exceptions=False,
3713
-            )
3714
-            assert result.clean_exit(), "expected clean exit"
3715
-            assert not result.stderr or all(
3716
-                map(is_warning_line, result.stderr.splitlines(True))
3717
-            )
3718
-            assert not caplog.record_tuples or machinery.warning_emitted(
3719
-                "A backup copy of the old notes was saved",
3720
-                caplog.record_tuples,
3721
-            ), "expected known warning message in stderr"
3722
-            assert (
3723
-                modern_editor_interface
3724
-                or notes_backup_file.read_text(encoding="UTF-8")
3725
-                == "Contents go here"
3726
-            )
3727
-            with cli_helpers.config_filename(subsystem="vault").open(
3728
-                encoding="UTF-8"
3729
-            ) as infile:
3730
-                config = json.load(infile)
3731
-            assert config == {
3732
-                "global": {"phrase": "abc"},
3733
-                "services": {"sv": {"notes": notes.strip()}},
3734
-            }
3735
-
3736
-    @hypothesis.given(
3737
-        notes=strategies.text(
3738
-            strategies.characters(
3739
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
3740
-            ),
3741
-            min_size=1,
3742
-            max_size=512,
3743
-        ).filter(str.strip),
3744
-    )
3745
-    def test_223_edit_notes_abort(
3746
-        self,
3747
-        notes: str,
3748
-    ) -> None:
3749
-        """Aborting editing notes works.
3750
-
3751
-        Aborting is only supported with the modern editor interface.
3752
-
3753
-        """
3754
-        runner = machinery.CliRunner(mix_stderr=False)
3755
-        # TODO(the-13th-letter): Rewrite using parenthesized
3756
-        # with-statements.
3757
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3758
-        with contextlib.ExitStack() as stack:
3759
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3760
-            stack.enter_context(
3761
-                pytest_machinery.isolated_vault_config(
3762
-                    monkeypatch=monkeypatch,
3763
-                    runner=runner,
3764
-                    vault_config={
3765
-                        "global": {"phrase": "abc"},
3766
-                        "services": {"sv": {"notes": notes.strip()}},
3767
-                    },
3768
-                )
3769
-            )
3770
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
3771
-            result = runner.invoke(
3772
-                cli.derivepassphrase_vault,
3773
-                [
3774
-                    "--config",
3775
-                    "--notes",
3776
-                    "--modern-editor-interface",
3777
-                    "--",
3778
-                    "sv",
3779
-                ],
3780
-                catch_exceptions=False,
3781
-            )
3782
-            assert result.error_exit(error="the user aborted the request"), (
3783
-                "expected known error message"
3784
-            )
3785
-            with cli_helpers.config_filename(subsystem="vault").open(
3786
-                encoding="UTF-8"
3787
-            ) as infile:
3788
-                config = json.load(infile)
3789
-            assert config == {
3790
-                "global": {"phrase": "abc"},
3791
-                "services": {"sv": {"notes": notes.strip()}},
3792
-            }
3793
-
3794
-    def test_223a_edit_empty_notes_abort(
3795
-        self,
3796
-    ) -> None:
3797
-        """Aborting editing notes works even if no notes are stored yet.
3798
-
3799
-        Aborting is only supported with the modern editor interface.
3800
-
3801
-        """
3802
-        runner = machinery.CliRunner(mix_stderr=False)
3803
-        # TODO(the-13th-letter): Rewrite using parenthesized
3804
-        # with-statements.
3805
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3806
-        with contextlib.ExitStack() as stack:
3807
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3808
-            stack.enter_context(
3809
-                pytest_machinery.isolated_vault_config(
3810
-                    monkeypatch=monkeypatch,
3811
-                    runner=runner,
3812
-                    vault_config={
3813
-                        "global": {"phrase": "abc"},
3814
-                        "services": {},
3815
-                    },
3816
-                )
3817
-            )
3818
-            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
3819
-            result = runner.invoke(
3820
-                cli.derivepassphrase_vault,
3821
-                [
3822
-                    "--config",
3823
-                    "--notes",
3824
-                    "--modern-editor-interface",
3825
-                    "--",
3826
-                    "sv",
3827
-                ],
3828
-                catch_exceptions=False,
3829
-            )
3830
-            assert result.error_exit(error="the user aborted the request"), (
3831
-                "expected known error message"
3832
-            )
3833
-            with cli_helpers.config_filename(subsystem="vault").open(
3834
-                encoding="UTF-8"
3835
-            ) as infile:
3836
-                config = json.load(infile)
3837
-            assert config == {
3838
-                "global": {"phrase": "abc"},
3839
-                "services": {},
3840
-            }
3841
-
3842
-    @Parametrize.MODERN_EDITOR_INTERFACE
3843
-    @hypothesis.settings(
3844
-        suppress_health_check=[
3845
-            *hypothesis.settings().suppress_health_check,
3846
-            hypothesis.HealthCheck.function_scoped_fixture,
3847
-        ],
3848
-    )
3849
-    @hypothesis.given(
3850
-        notes=strategies.text(
3851
-            strategies.characters(
3852
-                min_codepoint=32, max_codepoint=126, include_characters="\n"
3853
-            ),
3854
-            max_size=512,
3855
-        ),
3856
-    )
3857
-    def test_223b_edit_notes_fail_config_option_missing(
3858
-        self,
3859
-        caplog: pytest.LogCaptureFixture,
3860
-        modern_editor_interface: bool,
3861
-        notes: str,
3862
-    ) -> None:
3863
-        """Editing notes fails (and warns) if `--config` is missing."""
3864
-        maybe_notes = {"notes": notes.strip()} if notes.strip() else {}
3865
-        vault_config = {
3866
-            "global": {"phrase": DUMMY_PASSPHRASE},
3867
-            "services": {
3868
-                DUMMY_SERVICE: {**maybe_notes, **DUMMY_CONFIG_SETTINGS}
3869
-            },
3870
-        }
3871
-        # Reset caplog between hypothesis runs.
3872
-        caplog.clear()
3873
-        runner = machinery.CliRunner(mix_stderr=False)
3874
-        # TODO(the-13th-letter): Rewrite using parenthesized
3875
-        # with-statements.
3876
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3877
-        with contextlib.ExitStack() as stack:
3878
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3879
-            stack.enter_context(
3880
-                pytest_machinery.isolated_vault_config(
3881
-                    monkeypatch=monkeypatch,
3882
-                    runner=runner,
3883
-                    vault_config=vault_config,
3884
-                )
3885
-            )
3886
-            EDIT_ATTEMPTED = "edit attempted!"  # noqa: N806
3887
-
3888
-            def raiser(*_args: Any, **_kwargs: Any) -> NoReturn:
3889
-                pytest.fail(EDIT_ATTEMPTED)
3890
-
3891
-            notes_backup_file = cli_helpers.config_filename(
3892
-                subsystem="notes backup"
3893
-            )
3894
-            notes_backup_file.write_text(
3895
-                "These backup notes are left over from the previous session.",
3896
-                encoding="UTF-8",
3897
-            )
3898
-            monkeypatch.setattr(click, "edit", raiser)
3899
-            result = runner.invoke(
3900
-                cli.derivepassphrase_vault,
3901
-                [
3902
-                    "--notes",
3903
-                    "--modern-editor-interface"
3904
-                    if modern_editor_interface
3905
-                    else "--vault-legacy-editor-interface",
3906
-                    "--",
3907
-                    DUMMY_SERVICE,
3908
-                ],
3909
-                catch_exceptions=False,
3910
-            )
3911
-            assert result.clean_exit(
3912
-                output=DUMMY_RESULT_PASSPHRASE.decode("ascii")
3913
-            ), "expected clean exit"
3914
-            assert result.stderr
3915
-            assert notes.strip() in result.stderr
3916
-            assert all(
3917
-                is_warning_line(line)
3918
-                for line in result.stderr.splitlines(True)
3919
-                if line.startswith(f"{cli.PROG_NAME}: ")
3920
-            )
3921
-            assert machinery.warning_emitted(
3922
-                "Specifying --notes without --config is ineffective.  "
3923
-                "No notes will be edited.",
3924
-                caplog.record_tuples,
3925
-            ), "expected known warning message in stderr"
3926
-            assert (
3927
-                modern_editor_interface
3928
-                or notes_backup_file.read_text(encoding="UTF-8")
3929
-                == "These backup notes are left over from the previous session."
3930
-            )
3931
-            with cli_helpers.config_filename(subsystem="vault").open(
3932
-                encoding="UTF-8"
3933
-            ) as infile:
3934
-                config = json.load(infile)
3935
-            assert config == vault_config
3936
-
3937
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
3938
-    def test_224_store_config_good(
3939
-        self,
3940
-        command_line: list[str],
3941
-        input: str,
3942
-        result_config: Any,
3943
-    ) -> None:
3944
-        """Storing valid settings via `--config` works.
3945
-
3946
-        The format also contains embedded newlines and indentation to make
3947
-        the config more readable.
3948
-
3949
-        """
3950
-        runner = machinery.CliRunner(mix_stderr=False)
3951
-        # TODO(the-13th-letter): Rewrite using parenthesized
3952
-        # with-statements.
3953
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3954
-        with contextlib.ExitStack() as stack:
3955
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3956
-            stack.enter_context(
3957
-                pytest_machinery.isolated_vault_config(
3958
-                    monkeypatch=monkeypatch,
3959
-                    runner=runner,
3960
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
3961
-                )
3962
-            )
3963
-            monkeypatch.setattr(
3964
-                cli_helpers,
3965
-                "get_suitable_ssh_keys",
3966
-                callables.suitable_ssh_keys,
3967
-            )
3968
-            result = runner.invoke(
3969
-                cli.derivepassphrase_vault,
3970
-                ["--config", *command_line],
3971
-                catch_exceptions=False,
3972
-                input=input,
3973
-            )
3974
-            assert result.clean_exit(), "expected clean exit"
3975
-            config_txt = cli_helpers.config_filename(
3976
-                subsystem="vault"
3977
-            ).read_text(encoding="UTF-8")
3978
-            config = json.loads(config_txt)
3979
-            assert config == result_config, (
3980
-                "stored config does not match expectation"
3981
-            )
3982
-            assert_vault_config_is_indented_and_line_broken(config_txt)
3983
-
3984
-    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
3985
-    def test_225_store_config_fail(
3986
-        self,
3987
-        command_line: list[str],
3988
-        input: str,
3989
-        err_text: str,
3990
-    ) -> None:
3991
-        """Storing invalid settings via `--config` fails."""
3992
-        runner = machinery.CliRunner(mix_stderr=False)
3993
-        # TODO(the-13th-letter): Rewrite using parenthesized
3994
-        # with-statements.
3995
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
3996
-        with contextlib.ExitStack() as stack:
3997
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
3998
-            stack.enter_context(
3999
-                pytest_machinery.isolated_vault_config(
4000
-                    monkeypatch=monkeypatch,
4001
-                    runner=runner,
4002
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4003
-                )
4004
-            )
4005
-            monkeypatch.setattr(
4006
-                cli_helpers,
4007
-                "get_suitable_ssh_keys",
4008
-                callables.suitable_ssh_keys,
4009
-            )
4010
-            result = runner.invoke(
4011
-                cli.derivepassphrase_vault,
4012
-                ["--config", *command_line],
4013
-                catch_exceptions=False,
4014
-                input=input,
4015
-            )
4016
-        assert result.error_exit(error=err_text), (
4017
-            "expected error exit and known error message"
4018
-        )
4019
-
4020
-    def test_225a_store_config_fail_manual_no_ssh_key_selection(
4021
-        self,
4022
-        running_ssh_agent: data.RunningSSHAgentInfo,
4023
-    ) -> None:
4024
-        """Not selecting an SSH key during `--config --key` fails."""
4025
-        del running_ssh_agent
4026
-        runner = machinery.CliRunner(mix_stderr=False)
4027
-        # TODO(the-13th-letter): Rewrite using parenthesized
4028
-        # with-statements.
4029
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4030
-        with contextlib.ExitStack() as stack:
4031
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4032
-            stack.enter_context(
4033
-                pytest_machinery.isolated_vault_config(
4034
-                    monkeypatch=monkeypatch,
4035
-                    runner=runner,
4036
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4037
-                )
4038
-            )
4039
-
4040
-            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
4041
-                raise IndexError(cli_helpers.EMPTY_SELECTION)
4042
-
4043
-            monkeypatch.setattr(
4044
-                cli_helpers, "prompt_for_selection", prompt_for_selection
4045
-            )
4046
-            # Also patch the list of suitable SSH keys, lest we be at
4047
-            # the mercy of whatever SSH agent may be running.
4048
-            monkeypatch.setattr(
4049
-                cli_helpers,
4050
-                "get_suitable_ssh_keys",
4051
-                callables.suitable_ssh_keys,
4052
-            )
4053
-            result = runner.invoke(
4054
-                cli.derivepassphrase_vault,
4055
-                ["--key", "--config"],
4056
-                catch_exceptions=False,
4057
-            )
4058
-        assert result.error_exit(error="the user aborted the request"), (
4059
-            "expected error exit and known error message"
4060
-        )
4061
-
4062
-    def test_225b_store_config_fail_manual_no_ssh_agent(
4063
-        self,
4064
-        running_ssh_agent: data.RunningSSHAgentInfo,
4065
-    ) -> None:
4066
-        """Not running an SSH agent during `--config --key` fails."""
4067
-        del running_ssh_agent
4068
-        runner = machinery.CliRunner(mix_stderr=False)
4069
-        # TODO(the-13th-letter): Rewrite using parenthesized
4070
-        # with-statements.
4071
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4072
-        with contextlib.ExitStack() as stack:
4073
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4074
-            stack.enter_context(
4075
-                pytest_machinery.isolated_vault_config(
4076
-                    monkeypatch=monkeypatch,
4077
-                    runner=runner,
4078
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4079
-                )
4080
-            )
4081
-            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
4082
-            result = runner.invoke(
4083
-                cli.derivepassphrase_vault,
4084
-                ["--key", "--config"],
4085
-                catch_exceptions=False,
4086
-            )
4087
-        assert result.error_exit(error="Cannot find any running SSH agent"), (
4088
-            "expected error exit and known error message"
4089
-        )
4090
-
4091
-    def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
4092
-        self,
4093
-        running_ssh_agent: data.RunningSSHAgentInfo,
4094
-    ) -> None:
4095
-        """Not running a reachable SSH agent during `--config --key` fails."""
4096
-        running_ssh_agent.require_external_address()
4097
-        runner = machinery.CliRunner(mix_stderr=False)
4098
-        # TODO(the-13th-letter): Rewrite using parenthesized
4099
-        # with-statements.
4100
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4101
-        with contextlib.ExitStack() as stack:
4102
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4103
-            stack.enter_context(
4104
-                pytest_machinery.isolated_vault_config(
4105
-                    monkeypatch=monkeypatch,
4106
-                    runner=runner,
4107
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4108
-                )
4109
-            )
4110
-            cwd = pathlib.Path.cwd().resolve()
4111
-            monkeypatch.setenv("SSH_AUTH_SOCK", str(cwd))
4112
-            result = runner.invoke(
4113
-                cli.derivepassphrase_vault,
4114
-                ["--key", "--config"],
4115
-                catch_exceptions=False,
4116
-            )
4117
-        assert result.error_exit(error="Cannot connect to the SSH agent"), (
4118
-            "expected error exit and known error message"
4119
-        )
4120
-
4121
-    @Parametrize.TRY_RACE_FREE_IMPLEMENTATION
4122
-    def test_225d_store_config_fail_manual_read_only_file(
4123
-        self,
4124
-        try_race_free_implementation: bool,
4125
-    ) -> None:
4126
-        """Using a read-only configuration file with `--config` fails."""
4127
-        runner = machinery.CliRunner(mix_stderr=False)
4128
-        # TODO(the-13th-letter): Rewrite using parenthesized
4129
-        # with-statements.
4130
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4131
-        with contextlib.ExitStack() as stack:
4132
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4133
-            stack.enter_context(
4134
-                pytest_machinery.isolated_vault_config(
4135
-                    monkeypatch=monkeypatch,
4136
-                    runner=runner,
4137
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4138
-                )
4139
-            )
4140
-            callables.make_file_readonly(
4141
-                cli_helpers.config_filename(subsystem="vault"),
4142
-                try_race_free_implementation=try_race_free_implementation,
4143
-            )
4144
-            result = runner.invoke(
4145
-                cli.derivepassphrase_vault,
4146
-                ["--config", "--length=15", "--", DUMMY_SERVICE],
4147
-                catch_exceptions=False,
4148
-            )
4149
-        assert result.error_exit(error="Cannot store vault settings:"), (
4150
-            "expected error exit and known error message"
4151
-        )
4152
-
4153
-    def test_225e_store_config_fail_manual_custom_error(
4154
-        self,
4155
-    ) -> None:
4156
-        """OS-erroring with `--config` fails."""
4157
-        runner = machinery.CliRunner(mix_stderr=False)
4158
-        # TODO(the-13th-letter): Rewrite using parenthesized
4159
-        # with-statements.
4160
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4161
-        with contextlib.ExitStack() as stack:
4162
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4163
-            stack.enter_context(
4164
-                pytest_machinery.isolated_vault_config(
4165
-                    monkeypatch=monkeypatch,
4166
-                    runner=runner,
4167
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4168
-                )
4169
-            )
4170
-            custom_error = "custom error message"
4171
-
4172
-            def raiser(config: Any) -> None:
4173
-                del config
4174
-                raise RuntimeError(custom_error)
4175
-
4176
-            monkeypatch.setattr(cli_helpers, "save_config", raiser)
4177
-            result = runner.invoke(
4178
-                cli.derivepassphrase_vault,
4179
-                ["--config", "--length=15", "--", DUMMY_SERVICE],
4180
-                catch_exceptions=False,
4181
-            )
4182
-        assert result.error_exit(error=custom_error), (
4183
-            "expected error exit and known error message"
4184
-        )
4185
-
4186
-    def test_225f_store_config_fail_unset_and_set_same_settings(
4187
-        self,
4188
-    ) -> None:
4189
-        """Issuing conflicting settings to `--config` fails."""
4190
-        runner = machinery.CliRunner(mix_stderr=False)
4191
-        # TODO(the-13th-letter): Rewrite using parenthesized
4192
-        # with-statements.
4193
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4194
-        with contextlib.ExitStack() as stack:
4195
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4196
-            stack.enter_context(
4197
-                pytest_machinery.isolated_vault_config(
4198
-                    monkeypatch=monkeypatch,
4199
-                    runner=runner,
4200
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4201
-                )
4202
-            )
4203
-            result = runner.invoke(
4204
-                cli.derivepassphrase_vault,
4205
-                [
4206
-                    "--config",
4207
-                    "--unset=length",
4208
-                    "--length=15",
4209
-                    "--",
4210
-                    DUMMY_SERVICE,
4211
-                ],
4212
-                catch_exceptions=False,
4213
-            )
4214
-        assert result.error_exit(
4215
-            error="Attempted to unset and set --length at the same time."
4216
-        ), "expected error exit and known error message"
4217
-
4218
-    def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded(
4219
-        self,
4220
-        running_ssh_agent: data.RunningSSHAgentInfo,
4221
-    ) -> None:
4222
-        """Not holding any SSH keys during `--config --key` fails."""
4223
-        del running_ssh_agent
4224
-        runner = machinery.CliRunner(mix_stderr=False)
4225
-        # TODO(the-13th-letter): Rewrite using parenthesized
4226
-        # with-statements.
4227
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4228
-        with contextlib.ExitStack() as stack:
4229
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4230
-            stack.enter_context(
4231
-                pytest_machinery.isolated_vault_config(
4232
-                    monkeypatch=monkeypatch,
4233
-                    runner=runner,
4234
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4235
-                )
4236
-            )
4237
-
4238
-            def func(
4239
-                *_args: Any,
4240
-                **_kwargs: Any,
4241
-            ) -> list[_types.SSHKeyCommentPair]:
4242
-                return []
4243
-
4244
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
4245
-            result = runner.invoke(
4246
-                cli.derivepassphrase_vault,
4247
-                ["--key", "--config"],
4248
-                catch_exceptions=False,
4249
-            )
4250
-        assert result.error_exit(error="no keys suitable"), (
4251
-            "expected error exit and known error message"
4252
-        )
4253
-
4254
-    def test_225h_store_config_fail_manual_ssh_agent_runtime_error(
4255
-        self,
4256
-        running_ssh_agent: data.RunningSSHAgentInfo,
4257
-    ) -> None:
4258
-        """The SSH agent erroring during `--config --key` fails."""
4259
-        del running_ssh_agent
4260
-        runner = machinery.CliRunner(mix_stderr=False)
4261
-        # TODO(the-13th-letter): Rewrite using parenthesized
4262
-        # with-statements.
4263
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4264
-        with contextlib.ExitStack() as stack:
4265
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4266
-            stack.enter_context(
4267
-                pytest_machinery.isolated_vault_config(
4268
-                    monkeypatch=monkeypatch,
4269
-                    runner=runner,
4270
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4271
-                )
4272
-            )
4273
-
4274
-            def raiser(*_args: Any, **_kwargs: Any) -> None:
4275
-                raise ssh_agent.TrailingDataError()
4276
-
4277
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", raiser)
4278
-            result = runner.invoke(
4279
-                cli.derivepassphrase_vault,
4280
-                ["--key", "--config"],
4281
-                catch_exceptions=False,
4282
-            )
4283
-        assert result.error_exit(
4284
-            error="violates the communication protocol."
4285
-        ), "expected error exit and known error message"
4286
-
4287
-    def test_225i_store_config_fail_manual_ssh_agent_refuses(
4288
-        self,
4289
-        running_ssh_agent: data.RunningSSHAgentInfo,
4290
-    ) -> None:
4291
-        """The SSH agent refusing during `--config --key` fails."""
4292
-        del running_ssh_agent
4293
-        runner = machinery.CliRunner(mix_stderr=False)
4294
-        # TODO(the-13th-letter): Rewrite using parenthesized
4295
-        # with-statements.
4296
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4297
-        with contextlib.ExitStack() as stack:
4298
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4299
-            stack.enter_context(
4300
-                pytest_machinery.isolated_vault_config(
4301
-                    monkeypatch=monkeypatch,
4302
-                    runner=runner,
4303
-                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4304
-                )
4305
-            )
4306
-
4307
-            def func(*_args: Any, **_kwargs: Any) -> NoReturn:
4308
-                raise ssh_agent.SSHAgentFailedError(
4309
-                    _types.SSH_AGENT.FAILURE, b""
4310
-                )
4311
-
4312
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "list_keys", func)
4313
-            result = runner.invoke(
4314
-                cli.derivepassphrase_vault,
4315
-                ["--key", "--config"],
4316
-                catch_exceptions=False,
4317
-            )
4318
-        assert result.error_exit(error="refused to"), (
4319
-            "expected error exit and known error message"
4320
-        )
4321
-
4322
-    def test_226_no_arguments(self) -> None:
4323
-        """Calling `derivepassphrase vault` without any arguments fails."""
4324
-        runner = machinery.CliRunner(mix_stderr=False)
4325
-        # TODO(the-13th-letter): Rewrite using parenthesized
4326
-        # with-statements.
4327
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4328
-        with contextlib.ExitStack() as stack:
4329
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4330
-            stack.enter_context(
4331
-                pytest_machinery.isolated_config(
4332
-                    monkeypatch=monkeypatch,
4333
-                    runner=runner,
4334
-                )
4335
-            )
4336
-            result = runner.invoke(
4337
-                cli.derivepassphrase_vault, [], catch_exceptions=False
4338
-            )
4339
-        assert result.error_exit(
4340
-            error="Deriving a passphrase requires a SERVICE"
4341
-        ), "expected error exit and known error message"
4342
-
4343
-    def test_226a_no_passphrase_or_key(
4344
-        self,
4345
-    ) -> None:
4346
-        """Deriving a passphrase without a passphrase or key fails."""
4347
-        runner = machinery.CliRunner(mix_stderr=False)
4348
-        # TODO(the-13th-letter): Rewrite using parenthesized
4349
-        # with-statements.
4350
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4351
-        with contextlib.ExitStack() as stack:
4352
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4353
-            stack.enter_context(
4354
-                pytest_machinery.isolated_config(
4355
-                    monkeypatch=monkeypatch,
4356
-                    runner=runner,
4357
-                )
4358
-            )
4359
-            result = runner.invoke(
4360
-                cli.derivepassphrase_vault,
4361
-                ["--", DUMMY_SERVICE],
4362
-                catch_exceptions=False,
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
+        ],
4363 1739
     )
4364
-        assert result.error_exit(error="No passphrase or key was given"), (
4365
-            "expected error exit and known error message"
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),
4366 1748
     )
4367
-
4368
-    def test_230_config_directory_nonexistant(
1749
+    def test_220_edit_notes_successfully(
4369 1750
         self,
1751
+        caplog: pytest.LogCaptureFixture,
1752
+        modern_editor_interface: bool,
1753
+        notes: str,
4370 1754
     ) -> None:
4371
-        """Running without an existing config directory works.
4372
-
4373
-        This is a regression test; see [issue\u00a0#6][] for context.
4374
-
4375
-        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
1755
+        """Editing notes works."""
1756
+        marker = cli_messages.TranslatedString(
1757
+            cli_messages.Label.DERIVEPASSPHRASE_VAULT_NOTES_MARKER
1758
+        )
1759
+        edit_result = f"""
4376 1760
 
1761
+{marker}
1762
+{notes}
4377 1763
 """
1764
+        # Reset caplog between hypothesis runs.
1765
+        caplog.clear()
4378 1766
         runner = machinery.CliRunner(mix_stderr=False)
4379 1767
         # TODO(the-13th-letter): Rewrite using parenthesized
4380 1768
         # with-statements.
... ...
@@ -4382,45 +1770,89 @@ class TestCLI:
4382 1770
         with contextlib.ExitStack() as stack:
4383 1771
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4384 1772
             stack.enter_context(
4385
-                pytest_machinery.isolated_config(
1773
+                pytest_machinery.isolated_vault_config(
4386 1774
                     monkeypatch=monkeypatch,
4387 1775
                     runner=runner,
1776
+                    vault_config={
1777
+                        "global": {"phrase": "abc"},
1778
+                        "services": {"sv": {"notes": "Contents go here"}},
1779
+                    },
4388 1780
                 )
4389 1781
             )
4390
-            with contextlib.suppress(FileNotFoundError):
4391
-                shutil.rmtree(cli_helpers.config_filename(subsystem=None))
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)
4392 1790
             result = runner.invoke(
4393 1791
                 cli.derivepassphrase_vault,
4394
-                ["--config", "-p"],
1792
+                [
1793
+                    "--config",
1794
+                    "--notes",
1795
+                    "--modern-editor-interface"
1796
+                    if modern_editor_interface
1797
+                    else "--vault-legacy-editor-interface",
1798
+                    "--",
1799
+                    "sv",
1800
+                ],
4395 1801
                 catch_exceptions=False,
4396
-                input="abc\n",
4397 1802
             )
4398 1803
             assert result.clean_exit(), "expected clean exit"
4399
-            assert result.stderr == "Passphrase:", (
4400
-                "program unexpectedly failed?!"
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"
4401 1813
             )
4402 1814
             with cli_helpers.config_filename(subsystem="vault").open(
4403 1815
                 encoding="UTF-8"
4404 1816
             ) as infile:
4405
-                config_readback = json.load(infile)
4406
-            assert config_readback == {
1817
+                config = json.load(infile)
1818
+            assert config == {
4407 1819
                 "global": {"phrase": "abc"},
4408
-                "services": {},
4409
-            }, "config mismatch"
1820
+                "services": {
1821
+                    "sv": {
1822
+                        "notes": notes.strip()
1823
+                        if modern_editor_interface
1824
+                        else edit_result.strip()
1825
+                    }
1826
+                },
1827
+            }
4410 1828
 
4411
-    def test_230a_config_directory_not_a_file(
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(
4412 1840
         self,
1841
+        edit_func_name: Literal["empty", "space"],
1842
+        modern_editor_interface: bool,
1843
+        notes: str,
4413 1844
     ) -> None:
4414
-        """Erroring without an existing config directory errors normally.
4415
-
4416
-        That is, the missing configuration directory does not cause any
4417
-        errors by itself.
1845
+        """Abandoning edited notes works."""
4418 1846
 
4419
-        This is a regression test; see [issue\u00a0#6][] for context.
1847
+        def empty(text: str, *_args: Any, **_kwargs: Any) -> str:
1848
+            del text
1849
+            return ""
4420 1850
 
4421
-        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
1851
+        def space(text: str, *_args: Any, **_kwargs: Any) -> str:
1852
+            del text
1853
+            return "       " + notes.strip() + "\n\n\n\n\n\n"
4422 1854
 
4423
-        """
1855
+        edit_funcs = {"empty": empty, "space": space}
4424 1856
         runner = machinery.CliRunner(mix_stderr=False)
4425 1857
         # TODO(the-13th-letter): Rewrite using parenthesized
4426 1858
         # with-statements.
... ...
@@ -4428,77 +1860,88 @@ class TestCLI:
4428 1860
         with contextlib.ExitStack() as stack:
4429 1861
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4430 1862
             stack.enter_context(
4431
-                pytest_machinery.isolated_config(
1863
+                pytest_machinery.isolated_vault_config(
4432 1864
                     monkeypatch=monkeypatch,
4433 1865
                     runner=runner,
1866
+                    vault_config={
1867
+                        "global": {"phrase": "abc"},
1868
+                        "services": {"sv": {"notes": notes.strip()}},
1869
+                    },
4434 1870
                 )
4435 1871
             )
4436
-            save_config_ = cli_helpers.save_config
4437
-
4438
-            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
4439
-                config_dir = cli_helpers.config_filename(subsystem=None)
4440
-                with contextlib.suppress(FileNotFoundError):
4441
-                    shutil.rmtree(config_dir)
4442
-                config_dir.write_text("Obstruction!!\n")
4443
-                monkeypatch.setattr(cli_helpers, "save_config", save_config_)
4444
-                return save_config_(*args, **kwargs)
4445
-
4446
-            monkeypatch.setattr(
4447
-                cli_helpers, "save_config", obstruct_config_saving
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",
4448 1878
             )
1879
+            monkeypatch.setattr(click, "edit", edit_funcs[edit_func_name])
4449 1880
             result = runner.invoke(
4450 1881
                 cli.derivepassphrase_vault,
4451
-                ["--config", "-p"],
1882
+                [
1883
+                    "--config",
1884
+                    "--notes",
1885
+                    "--modern-editor-interface"
1886
+                    if modern_editor_interface
1887
+                    else "--vault-legacy-editor-interface",
1888
+                    "--",
1889
+                    "sv",
1890
+                ],
4452 1891
                 catch_exceptions=False,
4453
-                input="abc\n",
4454
-            )
4455
-            assert result.error_exit(error="Cannot store vault settings:"), (
4456
-                "expected error exit and known error message"
4457
-            )
4458
-
4459
-    def test_230b_store_config_custom_error(
4460
-        self,
4461
-    ) -> None:
4462
-        """Storing the configuration reacts even to weird errors."""
4463
-        runner = machinery.CliRunner(mix_stderr=False)
4464
-        # TODO(the-13th-letter): Rewrite using parenthesized
4465
-        # with-statements.
4466
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4467
-        with contextlib.ExitStack() as stack:
4468
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4469
-            stack.enter_context(
4470
-                pytest_machinery.isolated_config(
4471
-                    monkeypatch=monkeypatch,
4472
-                    runner=runner,
4473 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."
4474 1900
             )
4475
-            custom_error = "custom error message"
4476
-
4477
-            def raiser(config: Any) -> None:
4478
-                del config
4479
-                raise RuntimeError(custom_error)
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
+            }
4480 1909
 
4481
-            monkeypatch.setattr(cli_helpers, "save_config", raiser)
4482
-            result = runner.invoke(
4483
-                cli.derivepassphrase_vault,
4484
-                ["--config", "-p"],
4485
-                catch_exceptions=False,
4486
-                input="abc\n",
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
+        ],
4487 1918
     )
4488
-            assert result.error_exit(error=custom_error), (
4489
-                "expected error exit and known error message"
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),
4490 1927
     )
4491
-
4492
-    @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
4493
-    def test_300_unicode_normalization_form_warning(
1928
+    def test_222_edit_notes_marker_removed(
4494 1929
         self,
4495 1930
         caplog: pytest.LogCaptureFixture,
4496
-        main_config: str,
4497
-        command_line: list[str],
4498
-        input: str | None,
4499
-        warning_message: str,
1931
+        modern_editor_interface: bool,
1932
+        notes: str,
4500 1933
     ) -> None:
4501
-        """Using unnormalized Unicode passphrases warns."""
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()
4502 1945
         runner = machinery.CliRunner(mix_stderr=False)
4503 1946
         # TODO(the-13th-letter): Rewrite using parenthesized
4504 1947
         # with-statements.
... ...
@@ -4510,33 +1953,72 @@ class TestCLI:
4510 1953
                     monkeypatch=monkeypatch,
4511 1954
                     runner=runner,
4512 1955
                     vault_config={
4513
-                        "services": {
4514
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
4515
-                        }
1956
+                        "global": {"phrase": "abc"},
1957
+                        "services": {"sv": {"notes": "Contents go here"}},
4516 1958
                     },
4517
-                    main_config_str=main_config,
4518 1959
                 )
4519 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)
4520 1969
             result = runner.invoke(
4521 1970
                 cli.derivepassphrase_vault,
4522
-                ["--debug", *command_line],
1971
+                [
1972
+                    "--config",
1973
+                    "--notes",
1974
+                    "--modern-editor-interface"
1975
+                    if modern_editor_interface
1976
+                    else "--vault-legacy-editor-interface",
1977
+                    "--",
1978
+                    "sv",
1979
+                ],
4523 1980
                 catch_exceptions=False,
4524
-                input=input,
4525 1981
             )
4526 1982
             assert result.clean_exit(), "expected clean exit"
4527
-        assert machinery.warning_emitted(
4528
-            warning_message, caplog.record_tuples
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,
4529 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
+            }
4530 2003
 
4531
-    @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
4532
-    def test_301_unicode_normalization_form_error(
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(
4533 2014
         self,
4534
-        main_config: str,
4535
-        command_line: list[str],
4536
-        input: str | None,
4537
-        error_message: str,
2015
+        notes: str,
4538 2016
     ) -> None:
4539
-        """Using unknown Unicode normalization forms fails."""
2017
+        """Aborting editing notes works.
2018
+
2019
+        Aborting is only supported with the modern editor interface.
2020
+
2021
+        """
4540 2022
         runner = machinery.CliRunner(mix_stderr=False)
4541 2023
         # TODO(the-13th-letter): Rewrite using parenthesized
4542 2024
         # with-statements.
... ...
@@ -4548,32 +2030,43 @@ class TestCLI:
4548 2030
                     monkeypatch=monkeypatch,
4549 2031
                     runner=runner,
4550 2032
                     vault_config={
4551
-                        "services": {
4552
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
4553
-                        }
2033
+                        "global": {"phrase": "abc"},
2034
+                        "services": {"sv": {"notes": notes.strip()}},
4554 2035
                     },
4555
-                    main_config_str=main_config,
4556 2036
                 )
4557 2037
             )
2038
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
4558 2039
             result = runner.invoke(
4559 2040
                 cli.derivepassphrase_vault,
4560
-                command_line,
2041
+                [
2042
+                    "--config",
2043
+                    "--notes",
2044
+                    "--modern-editor-interface",
2045
+                    "--",
2046
+                    "sv",
2047
+                ],
4561 2048
                 catch_exceptions=False,
4562
-                input=input,
4563 2049
             )
4564
-        assert result.error_exit(
4565
-            error="The user configuration file is invalid."
4566
-        ), "expected error exit and known error message"
4567
-        assert result.error_exit(error=error_message), (
4568
-            "expected error exit and known error message"
2050
+            assert result.error_exit(error="the user aborted the request"), (
2051
+                "expected known error message"
4569 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
+            }
4570 2061
 
4571
-    @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES
4572
-    def test_301a_unicode_normalization_form_error_from_stored_config(
2062
+    def test_223a_edit_empty_notes_abort(
4573 2063
         self,
4574
-        command_line: list[str],
4575 2064
     ) -> None:
4576
-        """Using unknown Unicode normalization forms in the config fails."""
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
+        """
4577 2070
         runner = machinery.CliRunner(mix_stderr=False)
4578 2071
         # TODO(the-13th-letter): Rewrite using parenthesized
4579 2072
         # with-statements.
... ...
@@ -4585,35 +2078,66 @@ class TestCLI:
4585 2078
                     monkeypatch=monkeypatch,
4586 2079
                     runner=runner,
4587 2080
                     vault_config={
4588
-                        "services": {
4589
-                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
4590
-                        }
2081
+                        "global": {"phrase": "abc"},
2082
+                        "services": {},
4591 2083
                     },
4592
-                    main_config_str=(
4593
-                        "[vault]\ndefault-unicode-normalization-form = 'XXX'\n"
4594
-                    ),
4595 2084
                 )
4596 2085
             )
2086
+            monkeypatch.setattr(click, "edit", lambda *_a, **_kw: "")
4597 2087
             result = runner.invoke(
4598 2088
                 cli.derivepassphrase_vault,
4599
-                command_line,
4600
-                input=DUMMY_PASSPHRASE,
2089
+                [
2090
+                    "--config",
2091
+                    "--notes",
2092
+                    "--modern-editor-interface",
2093
+                    "--",
2094
+                    "sv",
2095
+                ],
4601 2096
                 catch_exceptions=False,
4602 2097
             )
4603
-            assert result.error_exit(
4604
-                error="The user configuration file is invalid."
4605
-            ), "expected error exit and known error message"
4606
-            assert result.error_exit(
4607
-                error=(
4608
-                    "Invalid value 'XXX' for config key "
4609
-                    "vault.default-unicode-normalization-form"
4610
-                ),
4611
-            ), "expected error exit and known error message"
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
+            }
4612 2109
 
4613
-    def test_310_bad_user_config_file(
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(
4614 2126
         self,
2127
+        caplog: pytest.LogCaptureFixture,
2128
+        modern_editor_interface: bool,
2129
+        notes: str,
4615 2130
     ) -> None:
4616
-        """Loading a user configuration file in an invalid format fails."""
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()
4617 2141
         runner = machinery.CliRunner(mix_stderr=False)
4618 2142
         # TODO(the-13th-letter): Rewrite using parenthesized
4619 2143
         # with-statements.
... ...
@@ -4624,24 +2148,73 @@ class TestCLI:
4624 2148
                 pytest_machinery.isolated_vault_config(
4625 2149
                     monkeypatch=monkeypatch,
4626 2150
                     runner=runner,
4627
-                    vault_config={"services": {}},
4628
-                    main_config_str="This file is not valid TOML.\n",
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"
4629 2161
             )
2162
+            notes_backup_file.write_text(
2163
+                "These backup notes are left over from the previous session.",
2164
+                encoding="UTF-8",
4630 2165
             )
2166
+            monkeypatch.setattr(click, "edit", raiser)
4631 2167
             result = runner.invoke(
4632 2168
                 cli.derivepassphrase_vault,
4633
-                ["--phrase", "--", DUMMY_SERVICE],
4634
-                input=DUMMY_PASSPHRASE,
2169
+                [
2170
+                    "--notes",
2171
+                    "--modern-editor-interface"
2172
+                    if modern_editor_interface
2173
+                    else "--vault-legacy-editor-interface",
2174
+                    "--",
2175
+                    DUMMY_SERVICE,
2176
+                ],
4635 2177
                 catch_exceptions=False,
4636 2178
             )
4637
-            assert result.error_exit(error="Cannot load user config:"), (
4638
-                "expected error exit and known error message"
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."
4639 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
4640 2204
 
4641
-    def test_311_bad_user_config_is_a_directory(
2205
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG
2206
+    def test_224_store_config_good(
4642 2207
         self,
2208
+        command_line: list[str],
2209
+        input: str,
2210
+        result_config: Any,
4643 2211
     ) -> None:
4644
-        """Loading a user configuration file in an invalid format fails."""
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
+        """
4645 2218
         runner = machinery.CliRunner(mix_stderr=False)
4646 2219
         # TODO(the-13th-letter): Rewrite using parenthesized
4647 2220
         # with-statements.
... ...
@@ -4652,30 +2225,38 @@ class TestCLI:
4652 2225
                 pytest_machinery.isolated_vault_config(
4653 2226
                     monkeypatch=monkeypatch,
4654 2227
                     runner=runner,
4655
-                    vault_config={"services": {}},
4656
-                    main_config_str="",
2228
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4657 2229
                 )
4658 2230
             )
4659
-            user_config = cli_helpers.config_filename(
4660
-                subsystem="user configuration"
2231
+            monkeypatch.setattr(
2232
+                cli_helpers,
2233
+                "get_suitable_ssh_keys",
2234
+                callables.suitable_ssh_keys,
4661 2235
             )
4662
-            user_config.unlink()
4663
-            user_config.mkdir(parents=True, exist_ok=True)
4664 2236
             result = runner.invoke(
4665 2237
                 cli.derivepassphrase_vault,
4666
-                ["--phrase", "--", DUMMY_SERVICE],
4667
-                input=DUMMY_PASSPHRASE,
2238
+                ["--config", *command_line],
4668 2239
                 catch_exceptions=False,
2240
+                input=input,
4669 2241
             )
4670
-            assert result.error_exit(error="Cannot load user config:"), (
4671
-                "expected error exit and known error message"
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"
4672 2249
             )
2250
+            assert_vault_config_is_indented_and_line_broken(config_txt)
4673 2251
 
4674
-    def test_400_missing_af_unix_support(
2252
+    @Parametrize.CONFIG_EDITING_VIA_CONFIG_FLAG_FAILURES
2253
+    def test_225_store_config_fail(
4675 2254
         self,
4676
-        caplog: pytest.LogCaptureFixture,
2255
+        command_line: list[str],
2256
+        input: str,
2257
+        err_text: str,
4677 2258
     ) -> None:
4678
-        """Querying the SSH agent without `AF_UNIX` support fails."""
2259
+        """Storing invalid settings via `--config` fails."""
4679 2260
         runner = machinery.CliRunner(mix_stderr=False)
4680 2261
         # TODO(the-13th-letter): Rewrite using parenthesized
4681 2262
         # with-statements.
... ...
@@ -4689,58 +2270,27 @@ class TestCLI:
4689 2270
                     vault_config={"global": {"phrase": "abc"}, "services": {}},
4690 2271
                 )
4691 2272
             )
4692
-            monkeypatch.setenv(
4693
-                "SSH_AUTH_SOCK", "the value doesn't even matter"
4694
-            )
4695 2273
             monkeypatch.setattr(
4696
-                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"]
2274
+                cli_helpers,
2275
+                "get_suitable_ssh_keys",
2276
+                callables.suitable_ssh_keys,
4697 2277
             )
4698
-            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
4699 2278
             result = runner.invoke(
4700 2279
                 cli.derivepassphrase_vault,
4701
-                ["--key", "--config"],
2280
+                ["--config", *command_line],
4702 2281
                 catch_exceptions=False,
2282
+                input=input,
4703 2283
             )
4704
-        assert result.error_exit(
4705
-            error="does not support communicating with it"
4706
-        ), "expected error exit and known error message"
4707
-        assert machinery.warning_emitted(
4708
-            "Cannot connect to an SSH agent via UNIX domain sockets",
4709
-            caplog.record_tuples,
4710
-        ), "expected known warning message in stderr"
4711
-
4712
-
4713
-class TestCLIUtils:
4714
-    """Tests for command-line utility functions."""
4715
-
4716
-    @Parametrize.BASE_CONFIG_VARIATIONS
4717
-    def test_100_load_config(
4718
-        self,
4719
-        config: Any,
4720
-    ) -> None:
4721
-        """[`cli_helpers.load_config`][] works for valid configurations."""
4722
-        runner = machinery.CliRunner(mix_stderr=False)
4723
-        # TODO(the-13th-letter): Rewrite using parenthesized
4724
-        # with-statements.
4725
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
4726
-        with contextlib.ExitStack() as stack:
4727
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
4728
-            stack.enter_context(
4729
-                pytest_machinery.isolated_vault_config(
4730
-                    monkeypatch=monkeypatch,
4731
-                    runner=runner,
4732
-                    vault_config=config,
4733
-                )
2284
+        assert result.error_exit(error=err_text), (
2285
+            "expected error exit and known error message"
4734 2286
         )
4735
-            config_filename = cli_helpers.config_filename(subsystem="vault")
4736
-            with config_filename.open(encoding="UTF-8") as fileobj:
4737
-                assert json.load(fileobj) == config
4738
-            assert cli_helpers.load_config() == config
4739 2287
 
4740
-    def test_110_save_bad_config(
2288
+    def test_225a_store_config_fail_manual_no_ssh_key_selection(
4741 2289
         self,
2290
+        running_ssh_agent: data.RunningSSHAgentInfo,
4742 2291
     ) -> None:
4743
-        """[`cli_helpers.save_config`][] fails for bad configurations."""
2292
+        """Not selecting an SSH key during `--config --key` fails."""
2293
+        del running_ssh_agent
4744 2294
         runner = machinery.CliRunner(mix_stderr=False)
4745 2295
         # TODO(the-13th-letter): Rewrite using parenthesized
4746 2296
         # with-statements.
... ...
@@ -4751,358 +2301,38 @@ class TestCLIUtils:
4751 2301
                 pytest_machinery.isolated_vault_config(
4752 2302
                     monkeypatch=monkeypatch,
4753 2303
                     runner=runner,
4754
-                    vault_config={},
2304
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
4755 2305
                 )
4756 2306
             )
4757
-            stack.enter_context(
4758
-                pytest.raises(ValueError, match="Invalid vault config")
4759
-            )
4760
-            cli_helpers.save_config(None)  # type: ignore[arg-type]
4761
-
4762
-    def test_111_prompt_for_selection_multiple(self) -> None:
4763
-        """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case."""
4764
-
4765
-        @click.command()
4766
-        @click.option("--heading", default="Our menu:")
4767
-        @click.argument("items", nargs=-1)
4768
-        def driver(heading: str, items: list[str]) -> None:
4769
-            # from https://montypython.fandom.com/wiki/Spam#The_menu
4770
-            items = items or [
4771
-                "Egg and bacon",
4772
-                "Egg, sausage and bacon",
4773
-                "Egg and spam",
4774
-                "Egg, bacon and spam",
4775
-                "Egg, bacon, sausage and spam",
4776
-                "Spam, bacon, sausage and spam",
4777
-                "Spam, egg, spam, spam, bacon and spam",
4778
-                "Spam, spam, spam, egg and spam",
4779
-                (
4780
-                    "Spam, spam, spam, spam, spam, spam, baked beans, "
4781
-                    "spam, spam, spam and spam"
4782
-                ),
4783
-                (
4784
-                    "Lobster thermidor aux crevettes with a mornay sauce "
4785
-                    "garnished with truffle paté, brandy "
4786
-                    "and a fried egg on top and spam"
4787
-                ),
4788
-            ]
4789
-            index = cli_helpers.prompt_for_selection(items, heading=heading)
4790
-            click.echo("A fine choice: ", nl=False)
4791
-            click.echo(items[index])
4792
-            click.echo("(Note: Vikings strictly optional.)")
4793 2307
 
4794
-        runner = machinery.CliRunner(mix_stderr=True)
4795
-        result = runner.invoke(driver, [], input="9")
4796
-        assert result.clean_exit(
4797
-            output="""\
4798
-Our menu:
4799
-[1] Egg and bacon
4800
-[2] Egg, sausage and bacon
4801
-[3] Egg and spam
4802
-[4] Egg, bacon and spam
4803
-[5] Egg, bacon, sausage and spam
4804
-[6] Spam, bacon, sausage and spam
4805
-[7] Spam, egg, spam, spam, bacon and spam
4806
-[8] Spam, spam, spam, egg and spam
4807
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4808
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4809
-Your selection? (1-10, leave empty to abort): 9
4810
-A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4811
-(Note: Vikings strictly optional.)
4812
-"""
4813
-        ), "expected clean exit"
4814
-        result = runner.invoke(
4815
-            driver, ["--heading="], input="\n", catch_exceptions=True
4816
-        )
4817
-        assert result.error_exit(error=IndexError), (
4818
-            "expected error exit and known error type"
4819
-        )
4820
-        assert (
4821
-            result.stdout
4822
-            == """\
4823
-[1] Egg and bacon
4824
-[2] Egg, sausage and bacon
4825
-[3] Egg and spam
4826
-[4] Egg, bacon and spam
4827
-[5] Egg, bacon, sausage and spam
4828
-[6] Spam, bacon, sausage and spam
4829
-[7] Spam, egg, spam, spam, bacon and spam
4830
-[8] Spam, spam, spam, egg and spam
4831
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4832
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4833
-Your selection? (1-10, leave empty to abort):\x20
4834
-"""
4835
-        ), "expected known output"
4836
-        # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
4837
-        # click prompting machinery, meaning that the mixed output will
4838
-        # incorrectly contain a line break, contrary to what the
4839
-        # documentation for click.prompt prescribes.
4840
-        result = runner.invoke(
4841
-            driver, ["--heading="], input="", catch_exceptions=True
4842
-        )
4843
-        assert result.error_exit(error=IndexError), (
4844
-            "expected error exit and known error type"
4845
-        )
4846
-        assert result.stdout in {
4847
-            """\
4848
-[1] Egg and bacon
4849
-[2] Egg, sausage and bacon
4850
-[3] Egg and spam
4851
-[4] Egg, bacon and spam
4852
-[5] Egg, bacon, sausage and spam
4853
-[6] Spam, bacon, sausage and spam
4854
-[7] Spam, egg, spam, spam, bacon and spam
4855
-[8] Spam, spam, spam, egg and spam
4856
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4857
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4858
-Your selection? (1-10, leave empty to abort):\x20
4859
-""",
4860
-            """\
4861
-[1] Egg and bacon
4862
-[2] Egg, sausage and bacon
4863
-[3] Egg and spam
4864
-[4] Egg, bacon and spam
4865
-[5] Egg, bacon, sausage and spam
4866
-[6] Spam, bacon, sausage and spam
4867
-[7] Spam, egg, spam, spam, bacon and spam
4868
-[8] Spam, spam, spam, egg and spam
4869
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4870
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4871
-Your selection? (1-10, leave empty to abort): """,
4872
-        }, "expected known output"
4873
-
4874
-    def test_112_prompt_for_selection_single(self) -> None:
4875
-        """[`cli_helpers.prompt_for_selection`][] works in the "single" case."""
4876
-
4877
-        @click.command()
4878
-        @click.option("--item", default="baked beans")
4879
-        @click.argument("prompt")
4880
-        def driver(item: str, prompt: str) -> None:
4881
-            try:
4882
-                cli_helpers.prompt_for_selection(
4883
-                    [item], heading="", single_choice_prompt=prompt
4884
-                )
4885
-            except IndexError:
4886
-                click.echo("Boo.")
4887
-                raise
4888
-            else:
4889
-                click.echo("Great!")
2308
+            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
2309
+                raise IndexError(cli_helpers.EMPTY_SELECTION)
4890 2310
 
4891
-        runner = machinery.CliRunner(mix_stderr=True)
4892
-        result = runner.invoke(
4893
-            driver, ["Will replace with spam. Confirm, y/n?"], input="y"
4894
-        )
4895
-        assert result.clean_exit(
4896
-            output="""\
4897
-[1] baked beans
4898
-Will replace with spam. Confirm, y/n? y
4899
-Great!
4900
-"""
4901
-        ), "expected clean exit"
4902
-        result = runner.invoke(
4903
-            driver,
4904
-            ['Will replace with spam, okay? (Please say "y" or "n".)'],
4905
-            input="\n",
4906
-        )
4907
-        assert result.error_exit(error=IndexError), (
4908
-            "expected error exit and known error type"
4909
-        )
4910
-        assert (
4911
-            result.stdout
4912
-            == """\
4913
-[1] baked beans
4914
-Will replace with spam, okay? (Please say "y" or "n".):\x20
4915
-Boo.
4916
-"""
4917
-        ), "expected known output"
4918
-        # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
4919
-        # click prompting machinery, meaning that the mixed output will
4920
-        # incorrectly contain a line break, contrary to what the
4921
-        # documentation for click.prompt prescribes.
4922
-        result = runner.invoke(
4923
-            driver,
4924
-            ['Will replace with spam, okay? (Please say "y" or "n".)'],
4925
-            input="",
4926
-        )
4927
-        assert result.error_exit(error=IndexError), (
4928
-            "expected error exit and known error type"
4929
-        )
4930
-        assert result.stdout in {
4931
-            """\
4932
-[1] baked beans
4933
-Will replace with spam, okay? (Please say "y" or "n".):\x20
4934
-Boo.
4935
-""",
4936
-            """\
4937
-[1] baked beans
4938
-Will replace with spam, okay? (Please say "y" or "n".): Boo.
4939
-""",
4940
-        }, "expected known output"
4941
-
4942
-    def test_113_prompt_for_passphrase(
4943
-        self,
4944
-    ) -> None:
4945
-        """[`cli_helpers.prompt_for_passphrase`][] works."""
4946
-        with pytest.MonkeyPatch.context() as monkeypatch:
4947 2311
             monkeypatch.setattr(
4948
-                click,
4949
-                "prompt",
4950
-                lambda *a, **kw: json.dumps({"args": a, "kwargs": kw}),
4951
-            )
4952
-            res = json.loads(cli_helpers.prompt_for_passphrase())
4953
-        err_msg = "missing arguments to passphrase prompt"
4954
-        assert "args" in res, err_msg
4955
-        assert "kwargs" in res, err_msg
4956
-        assert res["args"][:1] == ["Passphrase"], err_msg
4957
-        assert res["kwargs"].get("default") == "", err_msg
4958
-        assert not res["kwargs"].get("show_default", True), err_msg
4959
-        assert res["kwargs"].get("err"), err_msg
4960
-        assert res["kwargs"].get("hide_input"), err_msg
4961
-
4962
-    def test_120_standard_logging_context_manager(
4963
-        self,
4964
-        caplog: pytest.LogCaptureFixture,
4965
-        capsys: pytest.CaptureFixture[str],
4966
-    ) -> None:
4967
-        """The standard logging context manager works.
4968
-
4969
-        It registers its handlers, once, and emits formatted calls to
4970
-        standard error prefixed with the program name.
4971
-
4972
-        """
4973
-        prog_name = cli_machinery.StandardCLILogging.prog_name
4974
-        package_name = cli_machinery.StandardCLILogging.package_name
4975
-        logger = logging.getLogger(package_name)
4976
-        deprecation_logger = logging.getLogger(f"{package_name}.deprecation")
4977
-        logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging()
4978
-        with logging_cm:
4979
-            assert (
4980
-                sum(
4981
-                    1
4982
-                    for h in logger.handlers
4983
-                    if h is cli_machinery.StandardCLILogging.cli_handler
4984
-                )
4985
-                == 1
4986
-            )
4987
-            logger.warning("message 1")
4988
-            with logging_cm:
4989
-                deprecation_logger.warning("message 2")
4990
-                assert (
4991
-                    sum(
4992
-                        1
4993
-                        for h in logger.handlers
4994
-                        if h is cli_machinery.StandardCLILogging.cli_handler
4995
-                    )
4996
-                    == 1
4997
-                )
4998
-                assert capsys.readouterr() == (
4999
-                    "",
5000
-                    (
5001
-                        f"{prog_name}: Warning: message 1\n"
5002
-                        f"{prog_name}: Deprecation warning: message 2\n"
5003
-                    ),
5004
-                )
5005
-            logger.warning("message 3")
5006
-            assert (
5007
-                sum(
5008
-                    1
5009
-                    for h in logger.handlers
5010
-                    if h is cli_machinery.StandardCLILogging.cli_handler
2312
+                cli_helpers, "prompt_for_selection", prompt_for_selection
5011 2313
             )
5012
-                == 1
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,
5013 2320
             )
5014
-            assert capsys.readouterr() == (
5015
-                "",
5016
-                f"{prog_name}: Warning: message 3\n",
2321
+            result = runner.invoke(
2322
+                cli.derivepassphrase_vault,
2323
+                ["--key", "--config"],
2324
+                catch_exceptions=False,
5017 2325
             )
5018
-            assert caplog.record_tuples == [
5019
-                (package_name, logging.WARNING, "message 1"),
5020
-                (f"{package_name}.deprecation", logging.WARNING, "message 2"),
5021
-                (package_name, logging.WARNING, "message 3"),
5022
-            ]
5023
-
5024
-    def test_121_standard_logging_warnings_context_manager(
5025
-        self,
5026
-        caplog: pytest.LogCaptureFixture,
5027
-        capsys: pytest.CaptureFixture[str],
5028
-    ) -> None:
5029
-        """The standard warnings logging context manager works.
5030
-
5031
-        It registers its handlers, once, and emits formatted calls to
5032
-        standard error prefixed with the program name.  It also adheres
5033
-        to the global warnings filter concerning which messages it
5034
-        actually emits to standard error.
5035
-
5036
-        """
5037
-        warnings_cm = (
5038
-            cli_machinery.StandardCLILogging.ensure_standard_warnings_logging()
2326
+        assert result.error_exit(error="the user aborted the request"), (
2327
+            "expected error exit and known error message"
5039 2328
         )
5040
-        THE_FUTURE = "the future will be here sooner than you think"  # noqa: N806
5041
-        JUST_TESTING = "just testing whether warnings work"  # noqa: N806
5042
-        with warnings_cm:
5043
-            assert (
5044
-                sum(
5045
-                    1
5046
-                    for h in logging.getLogger("py.warnings").handlers
5047
-                    if h is cli_machinery.StandardCLILogging.warnings_handler
5048
-                )
5049
-                == 1
5050
-            )
5051
-            warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
5052
-            with warnings_cm:
5053
-                warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1)
5054
-                _out, err = capsys.readouterr()
5055
-                err_lines = err.splitlines(True)
5056
-                assert any(
5057
-                    f"UserWarning: {JUST_TESTING}" in line
5058
-                    for line in err_lines
5059
-                )
5060
-                assert any(
5061
-                    f"FutureWarning: {THE_FUTURE}" in line
5062
-                    for line in err_lines
5063
-                )
5064
-            warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
5065
-            _out, err = capsys.readouterr()
5066
-            err_lines = err.splitlines(True)
5067
-            assert any(
5068
-                f"UserWarning: {JUST_TESTING}" in line for line in err_lines
5069
-            )
5070
-            assert not any(
5071
-                f"FutureWarning: {THE_FUTURE}" in line for line in err_lines
5072
-            )
5073
-            record_tuples = caplog.record_tuples
5074
-            assert [tup[:2] for tup in record_tuples] == [
5075
-                ("py.warnings", logging.WARNING),
5076
-                ("py.warnings", logging.WARNING),
5077
-                ("py.warnings", logging.WARNING),
5078
-            ]
5079
-            assert f"UserWarning: {JUST_TESTING}" in record_tuples[0][2]
5080
-            assert f"FutureWarning: {THE_FUTURE}" in record_tuples[1][2]
5081
-            assert f"UserWarning: {JUST_TESTING}" in record_tuples[2][2]
5082 2329
 
5083
-    def export_as_sh_helper(
2330
+    def test_225b_store_config_fail_manual_no_ssh_agent(
5084 2331
         self,
5085
-        config: Any,
2332
+        running_ssh_agent: data.RunningSSHAgentInfo,
5086 2333
     ) -> None:
5087
-        """Emits a config in sh(1) format, then reads it back to verify it.
5088
-
5089
-        This function exports the configuration, sets up a new
5090
-        enviroment, then calls
5091
-        [`vault_config_exporter_shell_interpreter`][] on the export
5092
-        script, verifying that each command ran successfully and that
5093
-        the final configuration matches the initial one.
5094
-
5095
-        Args:
5096
-            config:
5097
-                The configuration to emit and read back.
5098
-
5099
-        """
5100
-        prog_name_list = ("derivepassphrase", "vault")
5101
-        with io.StringIO() as outfile:
5102
-            cli_helpers.print_config_as_sh_script(
5103
-                config, outfile=outfile, prog_name_list=prog_name_list
5104
-            )
5105
-            script = outfile.getvalue()
2334
+        """Not running an SSH agent during `--config --key` fails."""
2335
+        del running_ssh_agent
5106 2336
         runner = machinery.CliRunner(mix_stderr=False)
5107 2337
         # TODO(the-13th-letter): Rewrite using parenthesized
5108 2338
         # with-statements.
... ...
@@ -5113,266 +2343,25 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5113 2343
                 pytest_machinery.isolated_vault_config(
5114 2344
                     monkeypatch=monkeypatch,
5115 2345
                     runner=runner,
5116
-                    vault_config={"services": {}},
5117
-                )
5118
-            )
5119
-            for result in vault_config_exporter_shell_interpreter(script):
5120
-                assert result.clean_exit()
5121
-            assert cli_helpers.load_config() == config
5122
-
5123
-    @hypothesis.given(
5124
-        global_config_settable=hypothesis_machinery.vault_full_service_config(),
5125
-        global_config_importable=strategies.fixed_dictionaries(
5126
-            {},
5127
-            optional={
5128
-                "key": strategies.text(
5129
-                    alphabet=strategies.characters(
5130
-                        min_codepoint=32,
5131
-                        max_codepoint=126,
5132
-                    ),
5133
-                    max_size=128,
5134
-                ),
5135
-                "phrase": strategies.text(
5136
-                    alphabet=strategies.characters(
5137
-                        min_codepoint=32,
5138
-                        max_codepoint=126,
5139
-                    ),
5140
-                    max_size=64,
5141
-                ),
5142
-            },
5143
-        ),
2346
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
5144 2347
                 )
5145
-    def test_130a_export_as_sh_global(
5146
-        self,
5147
-        global_config_settable: _types.VaultConfigServicesSettings,
5148
-        global_config_importable: _types.VaultConfigServicesSettings,
5149
-    ) -> None:
5150
-        """Exporting configurations as sh(1) script works.
5151
-
5152
-        Here, we check global-only configurations which use both
5153
-        settings settable via `--config` and settings requiring
5154
-        `--import`.
5155
-
5156
-        The actual verification is done by [`export_as_sh_helper`][].
5157
-
5158
-        """
5159
-        config: _types.VaultConfig = {
5160
-            "global": global_config_settable | global_config_importable,
5161
-            "services": {},
5162
-        }
5163
-        assert _types.clean_up_falsy_vault_config_values(config) is not None
5164
-        assert _types.is_vault_config(config)
5165
-        return self.export_as_sh_helper(config)
5166
-
5167
-    @hypothesis.given(
5168
-        global_config_importable=strategies.fixed_dictionaries(
5169
-            {},
5170
-            optional={
5171
-                "key": strategies.text(
5172
-                    alphabet=strategies.characters(
5173
-                        min_codepoint=32,
5174
-                        max_codepoint=126,
5175
-                    ),
5176
-                    max_size=128,
5177
-                ),
5178
-                "phrase": strategies.text(
5179
-                    alphabet=strategies.characters(
5180
-                        min_codepoint=32,
5181
-                        max_codepoint=126,
5182
-                    ),
5183
-                    max_size=64,
5184
-                ),
5185
-            },
5186
-        ),
5187 2348
             )
5188
-    def test_130b_export_as_sh_global_only_imports(
5189
-        self,
5190
-        global_config_importable: _types.VaultConfigServicesSettings,
5191
-    ) -> None:
5192
-        """Exporting configurations as sh(1) script works.
5193
-
5194
-        Here, we check global-only configurations which only use
5195
-        settings requiring `--import`.
5196
-
5197
-        The actual verification is done by [`export_as_sh_helper`][].
5198
-
5199
-        """
5200
-        config: _types.VaultConfig = {
5201
-            "global": global_config_importable,
5202
-            "services": {},
5203
-        }
5204
-        assert _types.clean_up_falsy_vault_config_values(config) is not None
5205
-        assert _types.is_vault_config(config)
5206
-        if not config["global"]:
5207
-            config.pop("global")
5208
-        return self.export_as_sh_helper(config)
5209
-
5210
-    @hypothesis.given(
5211
-        service_name=strategies.text(
5212
-            alphabet=strategies.characters(
5213
-                min_codepoint=32,
5214
-                max_codepoint=126,
5215
-            ),
5216
-            min_size=4,
5217
-            max_size=64,
5218
-        ),
5219
-        service_config_settable=hypothesis_machinery.vault_full_service_config(),
5220
-        service_config_importable=strategies.fixed_dictionaries(
5221
-            {},
5222
-            optional={
5223
-                "key": strategies.text(
5224
-                    alphabet=strategies.characters(
5225
-                        min_codepoint=32,
5226
-                        max_codepoint=126,
5227
-                    ),
5228
-                    max_size=128,
5229
-                ),
5230
-                "phrase": strategies.text(
5231
-                    alphabet=strategies.characters(
5232
-                        min_codepoint=32,
5233
-                        max_codepoint=126,
5234
-                    ),
5235
-                    max_size=64,
5236
-                ),
5237
-                "notes": strategies.text(
5238
-                    alphabet=strategies.characters(
5239
-                        min_codepoint=32,
5240
-                        max_codepoint=126,
5241
-                        include_characters=("\n", "\f", "\t"),
5242
-                    ),
5243
-                    max_size=256,
5244
-                ),
5245
-            },
5246
-        ),
2349
+            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
2350
+            result = runner.invoke(
2351
+                cli.derivepassphrase_vault,
2352
+                ["--key", "--config"],
2353
+                catch_exceptions=False,
5247 2354
             )
5248
-    def test_130c_export_as_sh_service(
5249
-        self,
5250
-        service_name: str,
5251
-        service_config_settable: _types.VaultConfigServicesSettings,
5252
-        service_config_importable: _types.VaultConfigServicesSettings,
5253
-    ) -> None:
5254
-        """Exporting configurations as sh(1) script works.
5255
-
5256
-        Here, we check service-only configurations which use both
5257
-        settings settable via `--config` and settings requiring
5258
-        `--import`.
5259
-
5260
-        The actual verification is done by [`export_as_sh_helper`][].
5261
-
5262
-        """
5263
-        config: _types.VaultConfig = {
5264
-            "services": {
5265
-                service_name: (
5266
-                    service_config_settable | service_config_importable
5267
-                ),
5268
-            },
5269
-        }
5270
-        assert _types.clean_up_falsy_vault_config_values(config) is not None
5271
-        assert _types.is_vault_config(config)
5272
-        return self.export_as_sh_helper(config)
5273
-
5274
-    @hypothesis.given(
5275
-        service_name=strategies.text(
5276
-            alphabet=strategies.characters(
5277
-                min_codepoint=32,
5278
-                max_codepoint=126,
5279
-            ),
5280
-            min_size=4,
5281
-            max_size=64,
5282
-        ),
5283
-        service_config_importable=strategies.fixed_dictionaries(
5284
-            {},
5285
-            optional={
5286
-                "key": strategies.text(
5287
-                    alphabet=strategies.characters(
5288
-                        min_codepoint=32,
5289
-                        max_codepoint=126,
5290
-                    ),
5291
-                    max_size=128,
5292
-                ),
5293
-                "phrase": strategies.text(
5294
-                    alphabet=strategies.characters(
5295
-                        min_codepoint=32,
5296
-                        max_codepoint=126,
5297
-                    ),
5298
-                    max_size=64,
5299
-                ),
5300
-                "notes": strategies.text(
5301
-                    alphabet=strategies.characters(
5302
-                        min_codepoint=32,
5303
-                        max_codepoint=126,
5304
-                        include_characters=("\n", "\f", "\t"),
5305
-                    ),
5306
-                    max_size=256,
5307
-                ),
5308
-            },
5309
-        ),
2355
+        assert result.error_exit(error="Cannot find any running SSH agent"), (
2356
+            "expected error exit and known error message"
5310 2357
         )
5311
-    def test_130d_export_as_sh_service_only_imports(
5312
-        self,
5313
-        service_name: str,
5314
-        service_config_importable: _types.VaultConfigServicesSettings,
5315
-    ) -> None:
5316
-        """Exporting configurations as sh(1) script works.
5317
-
5318
-        Here, we check service-only configurations which only use
5319
-        settings requiring `--import`.
5320
-
5321
-        The actual verification is done by [`export_as_sh_helper`][].
5322
-
5323
-        """
5324
-        config: _types.VaultConfig = {
5325
-            "services": {
5326
-                service_name: service_config_importable,
5327
-            },
5328
-        }
5329
-        assert _types.clean_up_falsy_vault_config_values(config) is not None
5330
-        assert _types.is_vault_config(config)
5331
-        return self.export_as_sh_helper(config)
5332 2358
 
5333
-    # The Annoying OS appears to silently truncate spaces at the end of
5334
-    # filenames.
5335
-    @hypothesis.given(
5336
-        env_var=strategies.sampled_from(["TMPDIR", "TEMP", "TMP"]),
5337
-        suffix=strategies.builds(
5338
-            operator.add,
5339
-            strategies.text(
5340
-                tuple(" 0123456789abcdefghijklmnopqrstuvwxyz"),
5341
-                min_size=11,
5342
-                max_size=11,
5343
-            ),
5344
-            strategies.text(
5345
-                tuple("0123456789abcdefghijklmnopqrstuvwxyz"),
5346
-                min_size=1,
5347
-                max_size=1,
5348
-            ),
5349
-        ),
5350
-    )
5351
-    @hypothesis.example(env_var="", suffix=".")
5352
-    def test_140a_get_tempdir(
2359
+    def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
5353 2360
         self,
5354
-        env_var: str,
5355
-        suffix: str,
2361
+        running_ssh_agent: data.RunningSSHAgentInfo,
5356 2362
     ) -> None:
5357
-        """[`cli_helpers.get_tempdir`][] returns a temporary directory.
5358
-
5359
-        If it is not the same as the temporary directory determined by
5360
-        [`tempfile.gettempdir`][], then assert that
5361
-        `tempfile.gettempdir` returned the current directory and
5362
-        `cli_helpers.get_tempdir` returned the configuration directory.
5363
-
5364
-        """
5365
-
5366
-        @contextlib.contextmanager
5367
-        def make_temporary_directory(
5368
-            path: pathlib.Path,
5369
-        ) -> Iterator[pathlib.Path]:
5370
-            try:
5371
-                path.mkdir()
5372
-                yield path
5373
-            finally:
5374
-                shutil.rmtree(path)
5375
-
2363
+        """Not running a reachable SSH agent during `--config --key` fails."""
2364
+        running_ssh_agent.require_external_address()
5376 2365
         runner = machinery.CliRunner(mix_stderr=False)
5377 2366
         # TODO(the-13th-letter): Rewrite using parenthesized
5378 2367
         # with-statements.
... ...
@@ -5383,43 +2372,26 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5383 2372
                 pytest_machinery.isolated_vault_config(
5384 2373
                     monkeypatch=monkeypatch,
5385 2374
                     runner=runner,
5386
-                    vault_config={"services": {}},
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,
5387 2384
             )
2385
+        assert result.error_exit(error="Cannot connect to the SSH agent"), (
2386
+            "expected error exit and known error message"
5388 2387
         )
5389
-            old_tempdir = os.fsdecode(tempfile.gettempdir())
5390
-            monkeypatch.delenv("TMPDIR", raising=False)
5391
-            monkeypatch.delenv("TEMP", raising=False)
5392
-            monkeypatch.delenv("TMP", raising=False)
5393
-            monkeypatch.setattr(tempfile, "tempdir", None)
5394
-            temp_path = pathlib.Path.cwd() / suffix
5395
-            if env_var:
5396
-                monkeypatch.setenv(env_var, os.fsdecode(temp_path))
5397
-                stack.enter_context(make_temporary_directory(temp_path))
5398
-            new_tempdir = os.fsdecode(tempfile.gettempdir())
5399
-            hypothesis.assume(
5400
-                temp_path.resolve() == pathlib.Path.cwd().resolve()
5401
-                or old_tempdir != new_tempdir
5402
-            )
5403
-            system_tempdir = os.fsdecode(tempfile.gettempdir())
5404
-            our_tempdir = cli_helpers.get_tempdir()
5405
-            assert system_tempdir == os.fsdecode(our_tempdir) or (
5406
-                # TODO(the-13th-letter): `pytest_machinery.isolated_config`
5407
-                # guarantees that `Path.cwd() == config_filename(None)`.
5408
-                # So this sub-branch ought to never trigger in our
5409
-                # tests.
5410
-                system_tempdir == os.getcwd()  # noqa: PTH109
5411
-                and our_tempdir == cli_helpers.config_filename(subsystem=None)
5412
-            )
5413
-        assert not temp_path.exists(), f"temp path {temp_path} not cleaned up!"
5414
-
5415
-    def test_140b_get_tempdir_force_default(self) -> None:
5416
-        """[`cli_helpers.get_tempdir`][] returns a temporary directory.
5417
-
5418
-        If all candidates are mocked to fail for the standard temporary
5419
-        directory choices, then we return the `derivepassphrase`
5420
-        configuration directory.
5421 2388
 
5422
-        """
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."""
5423 2395
         runner = machinery.CliRunner(mix_stderr=False)
5424 2396
         # TODO(the-13th-letter): Rewrite using parenthesized
5425 2397
         # with-statements.
... ...
@@ -5430,51 +2402,26 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5430 2402
                 pytest_machinery.isolated_vault_config(
5431 2403
                     monkeypatch=monkeypatch,
5432 2404
                     runner=runner,
5433
-                    vault_config={"services": {}},
2405
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
5434 2406
                 )
5435 2407
             )
5436
-            monkeypatch.delenv("TMPDIR", raising=False)
5437
-            monkeypatch.delenv("TEMP", raising=False)
5438
-            monkeypatch.delenv("TMP", raising=False)
5439
-            config_dir = cli_helpers.config_filename(subsystem=None)
5440
-
5441
-            def is_dir_false(
5442
-                self: pathlib.Path,
5443
-                /,
5444
-                *,
5445
-                follow_symlinks: bool = False,
5446
-            ) -> bool:
5447
-                del self, follow_symlinks
5448
-                return False
5449
-
5450
-            def is_dir_error(
5451
-                self: pathlib.Path,
5452
-                /,
5453
-                *,
5454
-                follow_symlinks: bool = False,
5455
-            ) -> bool:
5456
-                del follow_symlinks
5457
-                raise OSError(
5458
-                    errno.EACCES,
5459
-                    os.strerror(errno.EACCES),
5460
-                    str(self),
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"
5461 2419
         )
5462 2420
 
5463
-            monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_false)
5464
-            assert cli_helpers.get_tempdir() == config_dir
5465
-
5466
-            monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_error)
5467
-            assert cli_helpers.get_tempdir() == config_dir
5468
-
5469
-    @Parametrize.DELETE_CONFIG_INPUT
5470
-    def test_203_repeated_config_deletion(
2421
+    def test_225e_store_config_fail_manual_custom_error(
5471 2422
         self,
5472
-        command_line: list[str],
5473
-        config: _types.VaultConfig,
5474
-        result_config: _types.VaultConfig,
5475 2423
     ) -> None:
5476
-        """Repeatedly removing the same parts of a configuration works."""
5477
-        for start_config in [config, result_config]:
2424
+        """OS-erroring with `--config` fails."""
5478 2425
         runner = machinery.CliRunner(mix_stderr=False)
5479 2426
         # TODO(the-13th-letter): Rewrite using parenthesized
5480 2427
         # with-statements.
... ...
@@ -5485,149 +2432,29 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5485 2432
                 pytest_machinery.isolated_vault_config(
5486 2433
                     monkeypatch=monkeypatch,
5487 2434
                     runner=runner,
5488
-                        vault_config=start_config,
2435
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
5489 2436
                 )
5490 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)
5491 2445
             result = runner.invoke(
5492 2446
                 cli.derivepassphrase_vault,
5493
-                    command_line,
2447
+                ["--config", "--length=15", "--", DUMMY_SERVICE],
5494 2448
                 catch_exceptions=False,
5495 2449
             )
5496
-                assert result.clean_exit(empty_stderr=True), (
5497
-                    "expected clean exit"
5498
-                )
5499
-                with cli_helpers.config_filename(subsystem="vault").open(
5500
-                    encoding="UTF-8"
5501
-                ) as infile:
5502
-                    config_readback = json.load(infile)
5503
-                assert config_readback == result_config
5504
-
5505
-    def test_204_phrase_from_key_manually(self) -> None:
5506
-        """The dummy service, key and config settings are consistent."""
5507
-        assert (
5508
-            vault.Vault(
5509
-                phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS
5510
-            ).generate(DUMMY_SERVICE)
5511
-            == DUMMY_RESULT_KEY1
5512
-        )
5513
-
5514
-    @Parametrize.VALIDATION_FUNCTION_INPUT
5515
-    def test_210a_validate_constraints_manually(
5516
-        self,
5517
-        vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
5518
-        input: int,
5519
-    ) -> None:
5520
-        """Command-line argument constraint validation works."""
5521
-        ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, [])
5522
-        param = cli.derivepassphrase_vault.params[0]
5523
-        assert vfunc(ctx, param, input) == input
5524
-
5525
-    @Parametrize.CONNECTION_HINTS
5526
-    def test_227_get_suitable_ssh_keys(
5527
-        self,
5528
-        running_ssh_agent: data.RunningSSHAgentInfo,
5529
-        conn_hint: str,
5530
-    ) -> None:
5531
-        """[`cli_helpers.get_suitable_ssh_keys`][] works."""
5532
-        with pytest.MonkeyPatch.context() as monkeypatch:
5533
-            monkeypatch.setattr(
5534
-                ssh_agent.SSHAgentClient,
5535
-                "list_keys",
5536
-                callables.list_keys,
2450
+        assert result.error_exit(error=custom_error), (
2451
+            "expected error exit and known error message"
5537 2452
         )
5538
-            hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None
5539
-            # TODO(the-13th-letter): Rewrite using structural pattern
5540
-            # matching.
5541
-            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5542
-            if conn_hint == "client":
5543
-                hint = ssh_agent.SSHAgentClient()
5544
-            elif conn_hint == "socket":
5545
-                if isinstance(
5546
-                    running_ssh_agent.socket, str
5547
-                ):  # pragma: no cover
5548
-                    if not hasattr(socket, "AF_UNIX"):
5549
-                        pytest.skip("socket module does not support AF_UNIX")
5550
-                    # socket.AF_UNIX is not defined everywhere.
5551
-                    hint = socket.socket(family=socket.AF_UNIX)  # type: ignore[attr-defined]
5552
-                    hint.connect(running_ssh_agent.socket)
5553
-                else:  # pragma: no cover
5554
-                    hint = running_ssh_agent.socket()
5555
-            else:
5556
-                assert conn_hint == "none"
5557
-                hint = None
5558
-            exception: Exception | None = None
5559
-            try:
5560
-                list(cli_helpers.get_suitable_ssh_keys(hint))
5561
-            except RuntimeError:  # pragma: no cover
5562
-                pass
5563
-            except Exception as e:  # noqa: BLE001 # pragma: no cover
5564
-                exception = e
5565
-            finally:
5566
-                assert exception is None, (
5567
-                    "exception querying suitable SSH keys"
5568
-                )
5569
-
5570
-    @Parametrize.KEY_TO_PHRASE_SETTINGS
5571
-    def test_400_key_to_phrase(
5572
-        self,
5573
-        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
5574
-        list_keys_action: ListKeysAction | None,
5575
-        system_support_action: SystemSupportAction | None,
5576
-        address_action: SocketAddressAction | None,
5577
-        sign_action: SignAction,
5578
-        pattern: str,
5579
-    ) -> None:
5580
-        """All errors in [`cli_helpers.key_to_phrase`][] are handled."""
5581
-
5582
-        class ErrCallback(BaseException):
5583
-            def __init__(self, *args: Any, **kwargs: Any) -> None:
5584
-                super().__init__(*args[:1])
5585
-                self.args = args
5586
-                self.kwargs = kwargs
5587 2453
 
5588
-        def err(*args: Any, **_kwargs: Any) -> NoReturn:
5589
-            raise ErrCallback(*args, **_kwargs)
5590
-
5591
-        with pytest.MonkeyPatch.context() as monkeypatch:
5592
-            loaded_keys = list(
5593
-                ssh_agent_client_with_test_keys_loaded.list_keys()
5594
-            )
5595
-            loaded_key = base64.standard_b64encode(loaded_keys[0][0])
5596
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, "sign", sign_action)
5597
-            if list_keys_action:
5598
-                monkeypatch.setattr(
5599
-                    ssh_agent.SSHAgentClient, "list_keys", list_keys_action
5600
-                )
5601
-            if address_action:
5602
-                address_action(monkeypatch)
5603
-            if system_support_action:
5604
-                system_support_action(monkeypatch)
5605
-            with pytest.raises(ErrCallback, match=pattern) as excinfo:
5606
-                cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5607
-            if list_keys_action == ListKeysAction.FAIL_RUNTIME:
5608
-                assert excinfo.value.kwargs
5609
-                assert isinstance(
5610
-                    excinfo.value.kwargs["exc_info"],
5611
-                    ssh_agent.SSHAgentFailedError,
5612
-                )
5613
-                assert excinfo.value.kwargs["exc_info"].__context__ is not None
5614
-                assert isinstance(
5615
-                    excinfo.value.kwargs["exc_info"].__context__,
5616
-                    ssh_agent.TrailingDataError,
5617
-                )
5618
-
5619
-
5620
-# TODO(the-13th-letter): Remove this class in v1.0.
5621
-# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0
5622
-class TestCLITransition:
5623
-    """Transition tests for the command-line interface up to v1.0."""
5624
-
5625
-    @Parametrize.BASE_CONFIG_VARIATIONS
5626
-    def test_110_load_config_backup(
2454
+    def test_225f_store_config_fail_unset_and_set_same_settings(
5627 2455
         self,
5628
-        config: Any,
5629 2456
     ) -> None:
5630
-        """Loading the old settings file works."""
2457
+        """Issuing conflicting settings to `--config` fails."""
5631 2458
         runner = machinery.CliRunner(mix_stderr=False)
5632 2459
         # TODO(the-13th-letter): Rewrite using parenthesized
5633 2460
         # with-statements.
... ...
@@ -5635,22 +2462,33 @@ class TestCLITransition:
5635 2462
         with contextlib.ExitStack() as stack:
5636 2463
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
5637 2464
             stack.enter_context(
5638
-                pytest_machinery.isolated_config(
2465
+                pytest_machinery.isolated_vault_config(
5639 2466
                     monkeypatch=monkeypatch,
5640 2467
                     runner=runner,
2468
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
2469
+                )
5641 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,
5642 2481
             )
5643
-            cli_helpers.config_filename(
5644
-                subsystem="old settings.json"
5645
-            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
5646
-            assert cli_helpers.migrate_and_load_old_config()[0] == config
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"
5647 2485
 
5648
-    @Parametrize.BASE_CONFIG_VARIATIONS
5649
-    def test_111_migrate_config(
2486
+    def test_225g_store_config_fail_manual_ssh_agent_no_keys_loaded(
5650 2487
         self,
5651
-        config: Any,
2488
+        running_ssh_agent: data.RunningSSHAgentInfo,
5652 2489
     ) -> None:
5653
-        """Migrating the old settings file works."""
2490
+        """Not holding any SSH keys during `--config --key` fails."""
2491
+        del running_ssh_agent
5654 2492
         runner = machinery.CliRunner(mix_stderr=False)
5655 2493
         # TODO(the-13th-letter): Rewrite using parenthesized
5656 2494
         # with-statements.
... ...
@@ -5658,22 +2496,35 @@ class TestCLITransition:
5658 2496
         with contextlib.ExitStack() as stack:
5659 2497
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
5660 2498
             stack.enter_context(
5661
-                pytest_machinery.isolated_config(
2499
+                pytest_machinery.isolated_vault_config(
5662 2500
                     monkeypatch=monkeypatch,
5663 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,
5664 2517
             )
2518
+        assert result.error_exit(error="no keys suitable"), (
2519
+            "expected error exit and known error message"
5665 2520
         )
5666
-            cli_helpers.config_filename(
5667
-                subsystem="old settings.json"
5668
-            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
5669
-            assert cli_helpers.migrate_and_load_old_config() == (config, None)
5670 2521
 
5671
-    @Parametrize.BASE_CONFIG_VARIATIONS
5672
-    def test_112_migrate_config_error(
2522
+    def test_225h_store_config_fail_manual_ssh_agent_runtime_error(
5673 2523
         self,
5674
-        config: Any,
2524
+        running_ssh_agent: data.RunningSSHAgentInfo,
5675 2525
     ) -> None:
5676
-        """Migrating the old settings file atop a directory fails."""
2526
+        """The SSH agent erroring during `--config --key` fails."""
2527
+        del running_ssh_agent
5677 2528
         runner = machinery.CliRunner(mix_stderr=False)
5678 2529
         # TODO(the-13th-letter): Rewrite using parenthesized
5679 2530
         # with-statements.
... ...
@@ -5681,29 +2532,32 @@ class TestCLITransition:
5681 2532
         with contextlib.ExitStack() as stack:
5682 2533
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
5683 2534
             stack.enter_context(
5684
-                pytest_machinery.isolated_config(
2535
+                pytest_machinery.isolated_vault_config(
5685 2536
                     monkeypatch=monkeypatch,
5686 2537
                     runner=runner,
2538
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
5687 2539
                 )
5688 2540
             )
5689
-            cli_helpers.config_filename(
5690
-                subsystem="old settings.json"
5691
-            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
5692
-            cli_helpers.config_filename(subsystem="vault").mkdir(
5693
-                parents=True, exist_ok=True
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,
5694 2550
             )
5695
-            config2, err = cli_helpers.migrate_and_load_old_config()
5696
-            assert config2 == config
5697
-            assert isinstance(err, OSError)
5698
-            # The Annoying OS uses EEXIST, other OSes use EISDIR.
5699
-            assert err.errno in {errno.EISDIR, errno.EEXIST}
2551
+        assert result.error_exit(
2552
+            error="violates the communication protocol."
2553
+        ), "expected error exit and known error message"
5700 2554
 
5701
-    @Parametrize.BAD_CONFIGS
5702
-    def test_113_migrate_config_error_bad_config_value(
2555
+    def test_225i_store_config_fail_manual_ssh_agent_refuses(
5703 2556
         self,
5704
-        config: Any,
2557
+        running_ssh_agent: data.RunningSSHAgentInfo,
5705 2558
     ) -> None:
5706
-        """Migrating an invalid old settings file fails."""
2559
+        """The SSH agent refusing during `--config --key` fails."""
2560
+        del running_ssh_agent
5707 2561
         runner = machinery.CliRunner(mix_stderr=False)
5708 2562
         # TODO(the-13th-letter): Rewrite using parenthesized
5709 2563
         # with-statements.
... ...
@@ -5711,25 +2565,30 @@ class TestCLITransition:
5711 2565
         with contextlib.ExitStack() as stack:
5712 2566
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
5713 2567
             stack.enter_context(
5714
-                pytest_machinery.isolated_config(
2568
+                pytest_machinery.isolated_vault_config(
5715 2569
                     monkeypatch=monkeypatch,
5716 2570
                     runner=runner,
2571
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
5717 2572
                 )
5718 2573
             )
5719
-            cli_helpers.config_filename(
5720
-                subsystem="old settings.json"
5721
-            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
5722
-            with pytest.raises(
5723
-                ValueError, match=cli_helpers.INVALID_VAULT_CONFIG
5724
-            ):
5725
-                cli_helpers.migrate_and_load_old_config()
5726 2574
 
5727
-    def test_200_forward_export_vault_path_parameter(
5728
-        self,
5729
-        caplog: pytest.LogCaptureFixture,
5730
-    ) -> None:
5731
-        """Forwarding arguments from "export" to "export vault" works."""
5732
-        pytest.importorskip("cryptography", minversion="38.0")
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."""
5733 2592
         runner = machinery.CliRunner(mix_stderr=False)
5734 2593
         # TODO(the-13th-letter): Rewrite using parenthesized
5735 2594
         # with-statements.
... ...
@@ -5737,33 +2596,22 @@ class TestCLITransition:
5737 2596
         with contextlib.ExitStack() as stack:
5738 2597
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
5739 2598
             stack.enter_context(
5740
-                pytest_machinery.isolated_vault_exporter_config(
2599
+                pytest_machinery.isolated_config(
5741 2600
                     monkeypatch=monkeypatch,
5742 2601
                     runner=runner,
5743
-                    vault_config=data.VAULT_V03_CONFIG,
5744
-                    vault_key=data.VAULT_MASTER_KEY,
5745 2602
                 )
5746 2603
             )
5747
-            monkeypatch.setenv("VAULT_KEY", data.VAULT_MASTER_KEY)
5748 2604
             result = runner.invoke(
5749
-                cli.derivepassphrase,
5750
-                ["export", "VAULT_PATH"],
5751
-            )
5752
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
5753
-        assert machinery.deprecation_warning_emitted(
5754
-            "A subcommand will be required here in v1.0", caplog.record_tuples
5755
-        )
5756
-        assert machinery.deprecation_warning_emitted(
5757
-            'Defaulting to subcommand "vault"', caplog.record_tuples
2605
+                cli.derivepassphrase_vault, [], catch_exceptions=False
5758 2606
             )
5759
-        assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA
2607
+        assert result.error_exit(
2608
+            error="Deriving a passphrase requires a SERVICE"
2609
+        ), "expected error exit and known error message"
5760 2610
 
5761
-    def test_201_forward_export_vault_empty_commandline(
2611
+    def test_226a_no_passphrase_or_key(
5762 2612
         self,
5763
-        caplog: pytest.LogCaptureFixture,
5764 2613
     ) -> None:
5765
-        """Deferring from "export" to "export vault" works."""
5766
-        pytest.importorskip("cryptography", minversion="38.0")
2614
+        """Deriving a passphrase without a passphrase or key fails."""
5767 2615
         runner = machinery.CliRunner(mix_stderr=False)
5768 2616
         # TODO(the-13th-letter): Rewrite using parenthesized
5769 2617
         # with-statements.
... ...
@@ -5777,28 +2625,24 @@ class TestCLITransition:
5777 2625
                 )
5778 2626
             )
5779 2627
             result = runner.invoke(
5780
-                cli.derivepassphrase,
5781
-                ["export"],
5782
-            )
5783
-        assert machinery.deprecation_warning_emitted(
5784
-            "A subcommand will be required here in v1.0", caplog.record_tuples
5785
-        )
5786
-        assert machinery.deprecation_warning_emitted(
5787
-            'Defaulting to subcommand "vault"', caplog.record_tuples
2628
+                cli.derivepassphrase_vault,
2629
+                ["--", DUMMY_SERVICE],
2630
+                catch_exceptions=False,
5788 2631
             )
5789
-        assert result.error_exit(error="Missing argument 'PATH'"), (
5790
-            "expected error exit and known error type"
2632
+        assert result.error_exit(error="No passphrase or key was given"), (
2633
+            "expected error exit and known error message"
5791 2634
         )
5792 2635
 
5793
-    @Parametrize.CHARSET_NAME
5794
-    def test_210_forward_vault_disable_character_set(
2636
+    def test_230_config_directory_nonexistant(
5795 2637
         self,
5796
-        caplog: pytest.LogCaptureFixture,
5797
-        charset_name: str,
5798 2638
     ) -> None:
5799
-        """Forwarding arguments from top-level to "vault" works."""
5800
-        option = f"--{charset_name}"
5801
-        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
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
+        """
5802 2646
         runner = machinery.CliRunner(mix_stderr=False)
5803 2647
         # TODO(the-13th-letter): Rewrite using parenthesized
5804 2648
         # with-statements.
... ...
@@ -5811,34 +2655,40 @@ class TestCLITransition:
5811 2655
                     runner=runner,
5812 2656
                 )
5813 2657
             )
5814
-            monkeypatch.setattr(
5815
-                cli_helpers,
5816
-                "prompt_for_passphrase",
5817
-                callables.auto_prompt,
5818
-            )
2658
+            with contextlib.suppress(FileNotFoundError):
2659
+                shutil.rmtree(cli_helpers.config_filename(subsystem=None))
5819 2660
             result = runner.invoke(
5820
-                cli.derivepassphrase,
5821
-                [option, "0", "-p", "--", DUMMY_SERVICE],
5822
-                input=DUMMY_PASSPHRASE,
2661
+                cli.derivepassphrase_vault,
2662
+                ["--config", "-p"],
5823 2663
                 catch_exceptions=False,
2664
+                input="abc\n",
5824 2665
             )
5825
-        assert result.clean_exit(empty_stderr=False), "expected clean exit"
5826
-        assert machinery.deprecation_warning_emitted(
5827
-            "A subcommand will be required here in v1.0", caplog.record_tuples
5828
-        )
5829
-        assert machinery.deprecation_warning_emitted(
5830
-            'Defaulting to subcommand "vault"', caplog.record_tuples
5831
-        )
5832
-        for c in charset:
5833
-            assert c not in result.stdout, (
5834
-                f"derived password contains forbidden character {c!r}"
2666
+            assert result.clean_exit(), "expected clean exit"
2667
+            assert result.stderr == "Passphrase:", (
2668
+                "program unexpectedly failed?!"
5835 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.
5836 2688
 
5837
-    def test_211_forward_vault_empty_command_line(
5838
-        self,
5839
-        caplog: pytest.LogCaptureFixture,
5840
-    ) -> None:
5841
-        """Deferring from top-level to "vault" works."""
2689
+        [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
2690
+
2691
+        """
5842 2692
         runner = machinery.CliRunner(mix_stderr=False)
5843 2693
         # TODO(the-13th-letter): Rewrite using parenthesized
5844 2694
         # with-statements.
... ...
@@ -5851,28 +2701,33 @@ class TestCLITransition:
5851 2701
                     runner=runner,
5852 2702
                 )
5853 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
+            )
5854 2717
             result = runner.invoke(
5855
-                cli.derivepassphrase,
5856
-                [],
5857
-                input=DUMMY_PASSPHRASE,
2718
+                cli.derivepassphrase_vault,
2719
+                ["--config", "-p"],
5858 2720
                 catch_exceptions=False,
2721
+                input="abc\n",
5859 2722
             )
5860
-        assert machinery.deprecation_warning_emitted(
5861
-            "A subcommand will be required here in v1.0", caplog.record_tuples
5862
-        )
5863
-        assert machinery.deprecation_warning_emitted(
5864
-            'Defaulting to subcommand "vault"', caplog.record_tuples
2723
+            assert result.error_exit(error="Cannot store vault settings:"), (
2724
+                "expected error exit and known error message"
5865 2725
             )
5866
-        assert result.error_exit(
5867
-            error="Deriving a passphrase requires a SERVICE."
5868
-        ), "expected error exit and known error type"
5869 2726
 
5870
-    def test_300_export_using_old_config_file(
2727
+    def test_230b_store_config_custom_error(
5871 2728
         self,
5872
-        caplog: pytest.LogCaptureFixture,
5873 2729
     ) -> None:
5874
-        """Exporting from (and migrating) the old settings file works."""
5875
-        caplog.set_level(logging.INFO)
2730
+        """Storing the configuration reacts even to weird errors."""
5876 2731
         runner = machinery.CliRunner(mix_stderr=False)
5877 2732
         # TODO(the-13th-letter): Rewrite using parenthesized
5878 2733
         # with-statements.
... ...
@@ -5885,34 +2740,33 @@ class TestCLITransition:
5885 2740
                     runner=runner,
5886 2741
                 )
5887 2742
             )
5888
-            cli_helpers.config_filename(
5889
-                subsystem="old settings.json"
5890
-            ).write_text(
5891
-                json.dumps(
5892
-                    {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
5893
-                    indent=2,
5894
-                )
5895
-                + "\n",
5896
-                encoding="UTF-8",
5897
-            )
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)
5898 2750
             result = runner.invoke(
5899 2751
                 cli.derivepassphrase_vault,
5900
-                ["--export", "-"],
2752
+                ["--config", "-p"],
5901 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"
5902 2758
             )
5903
-        assert result.clean_exit(), "expected clean exit"
5904
-        assert machinery.deprecation_warning_emitted(
5905
-            "v0.1-style config file", caplog.record_tuples
5906
-        ), "expected known warning message in stderr"
5907
-        assert machinery.deprecation_info_emitted(
5908
-            "Successfully migrated to ", caplog.record_tuples
5909
-        ), "expected known warning message in stderr"
5910 2759
 
5911
-    def test_300a_export_using_old_config_file_migration_error(
2760
+    @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS
2761
+    def test_300_unicode_normalization_form_warning(
5912 2762
         self,
5913 2763
         caplog: pytest.LogCaptureFixture,
2764
+        main_config: str,
2765
+        command_line: list[str],
2766
+        input: str | None,
2767
+        warning_message: str,
5914 2768
     ) -> None:
5915
-        """Exporting from (and not migrating) the old settings file fails."""
2769
+        """Using unnormalized Unicode passphrases warns."""
5916 2770
         runner = machinery.CliRunner(mix_stderr=False)
5917 2771
         # TODO(the-13th-letter): Rewrite using parenthesized
5918 2772
         # with-statements.
... ...
@@ -5920,49 +2774,37 @@ class TestCLITransition:
5920 2774
         with contextlib.ExitStack() as stack:
5921 2775
             monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
5922 2776
             stack.enter_context(
5923
-                pytest_machinery.isolated_config(
2777
+                pytest_machinery.isolated_vault_config(
5924 2778
                     monkeypatch=monkeypatch,
5925 2779
                     runner=runner,
2780
+                    vault_config={
2781
+                        "services": {
2782
+                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2783
+                        }
2784
+                    },
2785
+                    main_config_str=main_config,
5926 2786
                 )
5927 2787
             )
5928
-            cli_helpers.config_filename(
5929
-                subsystem="old settings.json"
5930
-            ).write_text(
5931
-                json.dumps(
5932
-                    {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
5933
-                    indent=2,
5934
-                )
5935
-                + "\n",
5936
-                encoding="UTF-8",
5937
-            )
5938
-
5939
-            def raiser(*_args: Any, **_kwargs: Any) -> None:
5940
-                raise OSError(
5941
-                    errno.EACCES,
5942
-                    os.strerror(errno.EACCES),
5943
-                    cli_helpers.config_filename(subsystem="vault"),
5944
-                )
5945
-
5946
-            monkeypatch.setattr(os, "replace", raiser)
5947
-            monkeypatch.setattr(pathlib.Path, "rename", raiser)
5948 2788
             result = runner.invoke(
5949 2789
                 cli.derivepassphrase_vault,
5950
-                ["--export", "-"],
2790
+                ["--debug", *command_line],
5951 2791
                 catch_exceptions=False,
2792
+                input=input,
5952 2793
             )
5953 2794
         assert result.clean_exit(), "expected clean exit"
5954
-        assert machinery.deprecation_warning_emitted(
5955
-            "v0.1-style config file", caplog.record_tuples
5956
-        ), "expected known warning message in stderr"
5957 2795
         assert machinery.warning_emitted(
5958
-            "Failed to migrate to ", caplog.record_tuples
2796
+            warning_message, caplog.record_tuples
5959 2797
         ), "expected known warning message in stderr"
5960 2798
 
5961
-    def test_400_completion_service_name_old_config_file(
2799
+    @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS
2800
+    def test_301_unicode_normalization_form_error(
5962 2801
         self,
2802
+        main_config: str,
2803
+        command_line: list[str],
2804
+        input: str | None,
2805
+        error_message: str,
5963 2806
     ) -> None:
5964
-        """Completing service names from the old settings file works."""
5965
-        config = {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}
2807
+        """Using unknown Unicode normalization forms fails."""
5966 2808
         runner = machinery.CliRunner(mix_stderr=False)
5967 2809
         # TODO(the-13th-letter): Rewrite using parenthesized
5968 2810
         # with-statements.
... ...
@@ -5973,138 +2815,33 @@ class TestCLITransition:
5973 2815
                 pytest_machinery.isolated_vault_config(
5974 2816
                     monkeypatch=monkeypatch,
5975 2817
                     runner=runner,
5976
-                    vault_config=config,
5977
-                )
2818
+                    vault_config={
2819
+                        "services": {
2820
+                            DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()
2821
+                        }
2822
+                    },
2823
+                    main_config_str=main_config,
5978 2824
                 )
5979
-            old_name = cli_helpers.config_filename(
5980
-                subsystem="old settings.json"
5981 2825
             )
5982
-            new_name = cli_helpers.config_filename(subsystem="vault")
5983
-            old_name.unlink(missing_ok=True)
5984
-            new_name.rename(old_name)
5985
-            assert cli_helpers.shell_complete_service(
5986
-                click.Context(cli.derivepassphrase),
5987
-                click.Argument(["some_parameter"]),
5988
-                "",
5989
-            ) == [DUMMY_SERVICE]
5990
-
5991
-
5992
-def completion_item(
5993
-    item: str | click.shell_completion.CompletionItem,
5994
-) -> click.shell_completion.CompletionItem:
5995
-    """Convert a string to a completion item, if necessary."""
5996
-    return (
5997
-        click.shell_completion.CompletionItem(item, type="plain")
5998
-        if isinstance(item, str)
5999
-        else item
2826
+            result = runner.invoke(
2827
+                cli.derivepassphrase_vault,
2828
+                command_line,
2829
+                catch_exceptions=False,
2830
+                input=input,
6000 2831
             )
6001
-
6002
-
6003
-def assertable_item(
6004
-    item: str | click.shell_completion.CompletionItem,
6005
-) -> tuple[str, Any, str | None]:
6006
-    """Convert a completion item into a pretty-printable item.
6007
-
6008
-    Intended to make completion items introspectable in pytest's
6009
-    `assert` output.
6010
-
6011
-    """
6012
-    item = completion_item(item)
6013
-    return (item.type, item.value, item.help)
6014
-
6015
-
6016
-class TestShellCompletion:
6017
-    """Tests for the shell completion machinery."""
6018
-
6019
-    class Completions:
6020
-        """A deferred completion call."""
6021
-
6022
-        def __init__(
6023
-            self,
6024
-            args: Sequence[str],
6025
-            incomplete: str,
6026
-        ) -> None:
6027
-            """Initialize the object.
6028
-
6029
-            Args:
6030
-                args:
6031
-                    The sequence of complete command-line arguments.
6032
-                incomplete:
6033
-                    The final, incomplete, partial argument.
6034
-
6035
-            """
6036
-            self.args = tuple(args)
6037
-            self.incomplete = incomplete
6038
-
6039
-        def __call__(self) -> Sequence[click.shell_completion.CompletionItem]:
6040
-            """Return the completion items."""
6041
-            args = list(self.args)
6042
-            completion = click.shell_completion.ShellComplete(
6043
-                cli=cli.derivepassphrase,
6044
-                ctx_args={},
6045
-                prog_name="derivepassphrase",
6046
-                complete_var="_DERIVEPASSPHRASE_COMPLETE",
6047
-            )
6048
-            return completion.get_completions(args, self.incomplete)
6049
-
6050
-        def get_words(self) -> Sequence[str]:
6051
-            """Return the completion items' values, as a sequence."""
6052
-            return tuple(c.value for c in self())
6053
-
6054
-    @Parametrize.COMPLETABLE_ITEMS
6055
-    def test_100_is_completable_item(
6056
-        self,
6057
-        partial: str,
6058
-        is_completable: bool,
6059
-    ) -> None:
6060
-        """Our `_is_completable_item` predicate for service names works."""
6061
-        assert cli_helpers.is_completable_item(partial) == is_completable
6062
-
6063
-    @Parametrize.COMPLETABLE_OPTIONS
6064
-    def test_200_options(
6065
-        self,
6066
-        command_prefix: Sequence[str],
6067
-        incomplete: str,
6068
-        completions: AbstractSet[str],
6069
-    ) -> None:
6070
-        """Our completion machinery works for all commands' options."""
6071
-        comp = self.Completions(command_prefix, incomplete)
6072
-        assert frozenset(comp.get_words()) == completions
6073
-
6074
-    @Parametrize.COMPLETABLE_SUBCOMMANDS
6075
-    def test_201_subcommands(
6076
-        self,
6077
-        command_prefix: Sequence[str],
6078
-        incomplete: str,
6079
-        completions: AbstractSet[str],
6080
-    ) -> None:
6081
-        """Our completion machinery works for all commands' subcommands."""
6082
-        comp = self.Completions(command_prefix, incomplete)
6083
-        assert frozenset(comp.get_words()) == completions
6084
-
6085
-    @Parametrize.COMPLETABLE_PATH_ARGUMENT
6086
-    @Parametrize.INCOMPLETE
6087
-    def test_202_paths(
6088
-        self,
6089
-        command_prefix: Sequence[str],
6090
-        incomplete: str,
6091
-    ) -> None:
6092
-        """Our completion machinery works for all commands' paths."""
6093
-        file = click.shell_completion.CompletionItem("", type="file")
6094
-        completions = frozenset({(file.type, file.value, file.help)})
6095
-        comp = self.Completions(command_prefix, incomplete)
6096
-        assert (
6097
-            frozenset((x.type, x.value, x.help) for x in comp()) == completions
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"
6098 2837
         )
6099 2838
 
6100
-    @Parametrize.COMPLETABLE_SERVICE_NAMES
6101
-    def test_203_service_names(
2839
+    @Parametrize.UNICODE_NORMALIZATION_COMMAND_LINES
2840
+    def test_301a_unicode_normalization_form_error_from_stored_config(
6102 2841
         self,
6103
-        config: _types.VaultConfig,
6104
-        incomplete: str,
6105
-        completions: AbstractSet[str],
2842
+        command_line: list[str],
6106 2843
     ) -> None:
6107
-        """Our completion machinery works for vault service names."""
2844
+        """Using unknown Unicode normalization forms in the config fails."""
6108 2845
         runner = machinery.CliRunner(mix_stderr=False)
6109 2846
         # TODO(the-13th-letter): Rewrite using parenthesized
6110 2847
         # with-statements.
... ...
@@ -6115,28 +2852,36 @@ class TestShellCompletion:
6115 2852
                 pytest_machinery.isolated_vault_config(
6116 2853
                     monkeypatch=monkeypatch,
6117 2854
                     runner=runner,
6118
-                    vault_config=config,
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
+                )
6119 2864
             )
2865
+            result = runner.invoke(
2866
+                cli.derivepassphrase_vault,
2867
+                command_line,
2868
+                input=DUMMY_PASSPHRASE,
2869
+                catch_exceptions=False,
6120 2870
             )
6121
-            comp = self.Completions(["vault"], incomplete)
6122
-            assert frozenset(comp.get_words()) == completions
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"
6123 2880
 
6124
-    @Parametrize.SHELL_FORMATTER
6125
-    @Parametrize.COMPLETION_FUNCTION_INPUTS
6126
-    def test_300_shell_completion_formatting(
2881
+    def test_310_bad_user_config_file(
6127 2882
         self,
6128
-        shell: str,
6129
-        format_func: Callable[[click.shell_completion.CompletionItem], str],
6130
-        config: _types.VaultConfig,
6131
-        comp_func: Callable[
6132
-            [click.Context, click.Parameter, str],
6133
-            list[str | click.shell_completion.CompletionItem],
6134
-        ],
6135
-        args: list[str],
6136
-        incomplete: str,
6137
-        results: list[str | click.shell_completion.CompletionItem],
6138 2883
     ) -> None:
6139
-        """Custom completion functions work for all shells."""
2884
+        """Loading a user configuration file in an invalid format fails."""
6140 2885
         runner = machinery.CliRunner(mix_stderr=False)
6141 2886
         # TODO(the-13th-letter): Rewrite using parenthesized
6142 2887
         # with-statements.
... ...
@@ -6147,58 +2892,24 @@ class TestShellCompletion:
6147 2892
                 pytest_machinery.isolated_vault_config(
6148 2893
                     monkeypatch=monkeypatch,
6149 2894
                     runner=runner,
6150
-                    vault_config=config,
6151
-                )
6152
-            )
6153
-            expected_items = [assertable_item(item) for item in results]
6154
-            expected_string = "\n".join(
6155
-                format_func(completion_item(item)) for item in results
6156
-            )
6157
-            manual_raw_items = comp_func(
6158
-                click.Context(cli.derivepassphrase),
6159
-                click.Argument(["sample_parameter"]),
6160
-                incomplete,
6161
-            )
6162
-            manual_items = [assertable_item(item) for item in manual_raw_items]
6163
-            manual_string = "\n".join(
6164
-                format_func(completion_item(item)) for item in manual_raw_items
2895
+                    vault_config={"services": {}},
2896
+                    main_config_str="This file is not valid TOML.\n",
6165 2897
                 )
6166
-            assert manual_items == expected_items
6167
-            assert manual_string == expected_string
6168
-            comp_class = click.shell_completion.get_completion_class(shell)
6169
-            assert comp_class is not None
6170
-            comp = comp_class(
6171
-                cli.derivepassphrase,
6172
-                {},
6173
-                "derivepassphrase",
6174
-                "_DERIVEPASSPHRASE_COMPLETE",
6175 2898
             )
6176
-            monkeypatch.setattr(
6177
-                comp,
6178
-                "get_completion_args",
6179
-                lambda *_a, **_kw: (args, incomplete),
2899
+            result = runner.invoke(
2900
+                cli.derivepassphrase_vault,
2901
+                ["--phrase", "--", DUMMY_SERVICE],
2902
+                input=DUMMY_PASSPHRASE,
2903
+                catch_exceptions=False,
6180 2904
             )
6181
-            actual_raw_items = comp.get_completions(
6182
-                *comp.get_completion_args()
2905
+            assert result.error_exit(error="Cannot load user config:"), (
2906
+                "expected error exit and known error message"
6183 2907
             )
6184
-            actual_items = [assertable_item(item) for item in actual_raw_items]
6185
-            actual_string = comp.complete()
6186
-            assert actual_items == expected_items
6187
-            assert actual_string == expected_string
6188 2908
 
6189
-    @Parametrize.CONFIG_SETTING_MODE
6190
-    @Parametrize.SERVICE_NAME_COMPLETION_INPUTS
6191
-    def test_400_incompletable_service_names(
2909
+    def test_311_bad_user_config_is_a_directory(
6192 2910
         self,
6193
-        caplog: pytest.LogCaptureFixture,
6194
-        mode: Literal["config", "import"],
6195
-        config: _types.VaultConfig,
6196
-        key: str,
6197
-        incomplete: str,
6198
-        completions: AbstractSet[str],
6199 2911
     ) -> None:
6200
-        """Completion skips incompletable items."""
6201
-        vault_config = config if mode == "config" else {"services": {}}
2912
+        """Loading a user configuration file in an invalid format fails."""
6202 2913
         runner = machinery.CliRunner(mix_stderr=False)
6203 2914
         # TODO(the-13th-letter): Rewrite using parenthesized
6204 2915
         # with-statements.
... ...
@@ -6209,37 +2920,30 @@ class TestShellCompletion:
6209 2920
                 pytest_machinery.isolated_vault_config(
6210 2921
                     monkeypatch=monkeypatch,
6211 2922
                     runner=runner,
6212
-                    vault_config=vault_config,
2923
+                    vault_config={"services": {}},
2924
+                    main_config_str="",
6213 2925
                 )
6214 2926
             )
6215
-            if mode == "config":
6216
-                result = runner.invoke(
6217
-                    cli.derivepassphrase_vault,
6218
-                    ["--config", "--length=10", "--", key],
6219
-                    catch_exceptions=False,
2927
+            user_config = cli_helpers.config_filename(
2928
+                subsystem="user configuration"
6220 2929
             )
6221
-            else:
2930
+            user_config.unlink()
2931
+            user_config.mkdir(parents=True, exist_ok=True)
6222 2932
             result = runner.invoke(
6223 2933
                 cli.derivepassphrase_vault,
6224
-                    ["--import", "-"],
2934
+                ["--phrase", "--", DUMMY_SERVICE],
2935
+                input=DUMMY_PASSPHRASE,
6225 2936
                 catch_exceptions=False,
6226
-                    input=json.dumps(config),
6227 2937
             )
6228
-            assert result.clean_exit(), "expected clean exit"
6229
-            assert machinery.warning_emitted(
6230
-                "contains an ASCII control character", caplog.record_tuples
6231
-            ), "expected known warning message in stderr"
6232
-            assert machinery.warning_emitted(
6233
-                "not be available for completion", caplog.record_tuples
6234
-            ), "expected known warning message in stderr"
6235
-            assert cli_helpers.load_config() == config
6236
-            comp = self.Completions(["vault"], incomplete)
6237
-            assert frozenset(comp.get_words()) == completions
2938
+            assert result.error_exit(error="Cannot load user config:"), (
2939
+                "expected error exit and known error message"
2940
+            )
6238 2941
 
6239
-    def test_410a_service_name_exceptions_not_found(
2942
+    def test_400_missing_af_unix_support(
6240 2943
         self,
2944
+        caplog: pytest.LogCaptureFixture,
6241 2945
     ) -> None:
6242
-        """Service name completion quietly fails on missing configuration."""
2946
+        """Querying the SSH agent without `AF_UNIX` support fails."""
6243 2947
         runner = machinery.CliRunner(mix_stderr=False)
6244 2948
         # TODO(the-13th-letter): Rewrite using parenthesized
6245 2949
         # with-statements.
... ...
@@ -6250,48 +2954,25 @@ class TestShellCompletion:
6250 2954
                 pytest_machinery.isolated_vault_config(
6251 2955
                     monkeypatch=monkeypatch,
6252 2956
                     runner=runner,
6253
-                    vault_config={
6254
-                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
6255
-                    },
6256
-                )
6257
-            )
6258
-            cli_helpers.config_filename(subsystem="vault").unlink(
6259
-                missing_ok=True
2957
+                    vault_config={"global": {"phrase": "abc"}, "services": {}},
6260 2958
                 )
6261
-            assert not cli_helpers.shell_complete_service(
6262
-                click.Context(cli.derivepassphrase),
6263
-                click.Argument(["some_parameter"]),
6264
-                "",
6265 2959
             )
6266
-
6267
-    @Parametrize.SERVICE_NAME_EXCEPTIONS
6268
-    def test_410b_service_name_exceptions_custom_error(
6269
-        self,
6270
-        exc_type: type[Exception],
6271
-    ) -> None:
6272
-        """Service name completion quietly fails on configuration errors."""
6273
-        runner = machinery.CliRunner(mix_stderr=False)
6274
-        # TODO(the-13th-letter): Rewrite using parenthesized
6275
-        # with-statements.
6276
-        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
6277
-        with contextlib.ExitStack() as stack:
6278
-            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
6279
-            stack.enter_context(
6280
-                pytest_machinery.isolated_vault_config(
6281
-                    monkeypatch=monkeypatch,
6282
-                    runner=runner,
6283
-                    vault_config={
6284
-                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
6285
-                    },
2960
+            monkeypatch.setenv(
2961
+                "SSH_AUTH_SOCK", "the value doesn't even matter"
6286 2962
             )
2963
+            monkeypatch.setattr(
2964
+                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", ["posix"]
6287 2965
             )
6288
-
6289
-            def raiser(*_a: Any, **_kw: Any) -> NoReturn:
6290
-                raise exc_type("just being difficult")  # noqa: EM101,TRY003
6291
-
6292
-            monkeypatch.setattr(cli_helpers, "load_config", raiser)
6293
-            assert not cli_helpers.shell_complete_service(
6294
-                click.Context(cli.derivepassphrase),
6295
-                click.Argument(["some_parameter"]),
6296
-                "",
2966
+            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
2967
+            result = runner.invoke(
2968
+                cli.derivepassphrase_vault,
2969
+                ["--key", "--config"],
2970
+                catch_exceptions=False,
6297 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,771 @@
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 enum
9
+import re
10
+import types
11
+
12
+import exceptiongroup
13
+import pytest
14
+from typing_extensions import NamedTuple
15
+
16
+from derivepassphrase import _types, cli, ssh_agent
17
+from derivepassphrase._internals import cli_messages
18
+from tests import machinery
19
+from tests.machinery import pytest as pytest_machinery
20
+
21
+
22
+class VersionOutputData(NamedTuple):
23
+    derivation_schemes: dict[str, bool]
24
+    foreign_configuration_formats: dict[str, bool]
25
+    extras: frozenset[str]
26
+    subcommands: frozenset[str]
27
+    features: dict[str, bool]
28
+
29
+
30
+class KnownLineType(str, enum.Enum):
31
+    SUPPORTED_FOREIGN_CONFS = cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip(
32
+        ":"
33
+    )
34
+    UNAVAILABLE_FOREIGN_CONFS = cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS.value.singular.rstrip(
35
+        ":"
36
+    )
37
+    SUPPORTED_SCHEMES = (
38
+        cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES.value.singular.rstrip(
39
+            ":"
40
+        )
41
+    )
42
+    UNAVAILABLE_SCHEMES = cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES.value.singular.rstrip(
43
+        ":"
44
+    )
45
+    SUPPORTED_SUBCOMMANDS = (
46
+        cli_messages.Label.SUPPORTED_SUBCOMMANDS.value.singular.rstrip(":")
47
+    )
48
+    SUPPORTED_FEATURES = (
49
+        cli_messages.Label.SUPPORTED_FEATURES.value.singular.rstrip(":")
50
+    )
51
+    UNAVAILABLE_FEATURES = (
52
+        cli_messages.Label.UNAVAILABLE_FEATURES.value.singular.rstrip(":")
53
+    )
54
+    ENABLED_EXTRAS = (
55
+        cli_messages.Label.ENABLED_PEP508_EXTRAS.value.singular.rstrip(":")
56
+    )
57
+
58
+
59
+class Parametrize(types.SimpleNamespace):
60
+    """Common test parametrizations."""
61
+
62
+    EAGER_ARGUMENTS = pytest.mark.parametrize(
63
+        "arguments",
64
+        [["--help"], ["--version"]],
65
+        ids=["help", "version"],
66
+    )
67
+    COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize(
68
+        ["command", "non_eager_arguments"],
69
+        [
70
+            pytest.param(
71
+                [],
72
+                [],
73
+                id="top-nothing",
74
+            ),
75
+            pytest.param(
76
+                [],
77
+                ["export"],
78
+                id="top-export",
79
+            ),
80
+            pytest.param(
81
+                ["export"],
82
+                [],
83
+                id="export-nothing",
84
+            ),
85
+            pytest.param(
86
+                ["export"],
87
+                ["vault"],
88
+                id="export-vault",
89
+            ),
90
+            pytest.param(
91
+                ["export", "vault"],
92
+                [],
93
+                id="export-vault-nothing",
94
+            ),
95
+            pytest.param(
96
+                ["export", "vault"],
97
+                ["--format", "this-format-doesnt-exist"],
98
+                id="export-vault-args",
99
+            ),
100
+            pytest.param(
101
+                ["vault"],
102
+                [],
103
+                id="vault-nothing",
104
+            ),
105
+            pytest.param(
106
+                ["vault"],
107
+                ["--export", "./"],
108
+                id="vault-args",
109
+            ),
110
+        ],
111
+    )
112
+    COLORFUL_COMMAND_INPUT = pytest.mark.parametrize(
113
+        ["command_line", "input"],
114
+        [
115
+            (
116
+                ["vault", "--import", "-"],
117
+                '{"services": {"": {"length": 20}}}',
118
+            ),
119
+        ],
120
+        ids=["cmd"],
121
+    )
122
+    ISATTY = pytest.mark.parametrize(
123
+        "isatty",
124
+        [False, True],
125
+        ids=["notty", "tty"],
126
+    )
127
+    MASK_PROG_NAME = pytest.mark.parametrize("mask_prog_name", [False, True])
128
+    MASK_VERSION = pytest.mark.parametrize("mask_version", [False, True])
129
+    VERSION_OUTPUT_DATA = pytest.mark.parametrize(
130
+        ["version_output", "prog_name", "version", "expected_parse"],
131
+        [
132
+            pytest.param(
133
+                """\
134
+derivepassphrase 0.4.0
135
+Using cryptography 44.0.0
136
+
137
+Supported foreign configuration formats: vault storeroom, vault v0.2,
138
+    vault v0.3.
139
+PEP 508 extras: export.
140
+""",
141
+                "derivepassphrase",
142
+                "0.4.0",
143
+                VersionOutputData(
144
+                    derivation_schemes={},
145
+                    foreign_configuration_formats={
146
+                        "vault storeroom": True,
147
+                        "vault v0.2": True,
148
+                        "vault v0.3": True,
149
+                    },
150
+                    subcommands=frozenset(),
151
+                    features={},
152
+                    extras=frozenset({"export"}),
153
+                ),
154
+                id="derivepassphrase-0.4.0-export",
155
+            ),
156
+            pytest.param(
157
+                """\
158
+derivepassphrase 0.5
159
+
160
+Supported derivation schemes: vault.
161
+Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3.
162
+Supported subcommands: export, vault.
163
+No PEP 508 extras are active.
164
+""",
165
+                "derivepassphrase",
166
+                "0.5",
167
+                VersionOutputData(
168
+                    derivation_schemes={"vault": True},
169
+                    foreign_configuration_formats={
170
+                        "vault storeroom": False,
171
+                        "vault v0.2": False,
172
+                        "vault v0.3": False,
173
+                    },
174
+                    subcommands=frozenset({"export", "vault"}),
175
+                    features={},
176
+                    extras=frozenset({}),
177
+                ),
178
+                id="derivepassphrase-0.5-plain",
179
+            ),
180
+            pytest.param(
181
+                """\
182
+
183
+
184
+
185
+inventpassphrase -1.3
186
+Using not-a-library 7.12
187
+Copyright 2025 Nobody.  All rights reserved.
188
+
189
+Supported derivation schemes: nonsense.
190
+Known derivation schemes: divination, /dev/random,
191
+    geiger counter,
192
+    crossword solver.
193
+Supported foreign configuration formats: derivepassphrase, nonsense.
194
+Known foreign configuration formats: divination v3.141592,
195
+    /dev/random.
196
+Supported subcommands: delete-all-files, dump-core.
197
+Supported features: delete-while-open.
198
+Known features: backups-are-nice-to-have.
199
+PEP 508 extras: annoying-popups, delete-all-files,
200
+    dump-core-depending-on-the-phase-of-the-moon.
201
+
202
+
203
+
204
+""",
205
+                "inventpassphrase",
206
+                "-1.3",
207
+                VersionOutputData(
208
+                    derivation_schemes={
209
+                        "nonsense": True,
210
+                        "divination": False,
211
+                        "/dev/random": False,
212
+                        "geiger counter": False,
213
+                        "crossword solver": False,
214
+                    },
215
+                    foreign_configuration_formats={
216
+                        "derivepassphrase": True,
217
+                        "nonsense": True,
218
+                        "divination v3.141592": False,
219
+                        "/dev/random": False,
220
+                    },
221
+                    subcommands=frozenset({"delete-all-files", "dump-core"}),
222
+                    features={
223
+                        "delete-while-open": True,
224
+                        "backups-are-nice-to-have": False,
225
+                    },
226
+                    extras=frozenset({
227
+                        "annoying-popups",
228
+                        "delete-all-files",
229
+                        "dump-core-depending-on-the-phase-of-the-moon",
230
+                    }),
231
+                ),
232
+                id="inventpassphrase",
233
+            ),
234
+        ],
235
+    )
236
+    """Sample data for [`parse_version_output`][]."""
237
+
238
+
239
+def parse_version_output(  # noqa: C901
240
+    version_output: str,
241
+    /,
242
+    *,
243
+    prog_name: str | None = cli_messages.PROG_NAME,
244
+    version: str | None = cli_messages.VERSION,
245
+) -> VersionOutputData:
246
+    r"""Parse the output of the `--version` option.
247
+
248
+    The version output contains two paragraphs.  The first paragraph
249
+    details the version number, and the version number of any major
250
+    libraries in use.  The second paragraph details known and supported
251
+    passphrase derivation schemes, foreign configuration formats,
252
+    subcommands and PEP 508 package extras.  For the schemes and
253
+    formats, there is a "supported" line for supported items, and
254
+    a "known" line for known but currently unsupported items (usually
255
+    because of missing dependencies), either of which may be empty and
256
+    thus omitted.  For extras, only active items are shown, and there is
257
+    a separate message for the "no extras active" case.  Item lists may
258
+    be spilled across multiple lines, but only at item boundaries, and
259
+    the continuation lines are then indented.
260
+
261
+    Args:
262
+        version_output:
263
+            The version output text to parse.
264
+        prog_name:
265
+            The program name to assert, defaulting to the true program
266
+            name, `derivepassphrase`.  Set to `None` to disable this
267
+            check.
268
+        version:
269
+            The program version to assert, defaulting to the true
270
+            current version of `derivepassphrase`.  Set to `None` to
271
+            disable this check.
272
+
273
+    Examples:
274
+        See [`Parametrize.VERSION_OUTPUT_DATA`][].
275
+
276
+    """
277
+    paragraphs: list[list[str]] = []
278
+    paragraph: list[str] = []
279
+    for line in version_output.splitlines(keepends=False):
280
+        if not line.strip():
281
+            if paragraph:
282
+                paragraphs.append(paragraph.copy())
283
+            paragraph.clear()
284
+        elif paragraph and line.lstrip() != line:
285
+            paragraph[-1] = f"{paragraph[-1]} {line.lstrip()}"
286
+        else:
287
+            paragraph.append(line)
288
+    if paragraph:  # pragma: no branch
289
+        paragraphs.append(paragraph.copy())
290
+        paragraph.clear()
291
+    assert paragraphs, (
292
+        f"expected at least one paragraph of version output: {paragraphs!r}"
293
+    )
294
+    assert prog_name is None or prog_name in paragraphs[0][0], (
295
+        f"first version output line should mention "
296
+        f"{prog_name}: {paragraphs[0][0]!r}"
297
+    )
298
+    assert version is None or version in paragraphs[0][0], (
299
+        f"first version output line should mention the version number "
300
+        f"{version}: {paragraphs[0][0]!r}"
301
+    )
302
+    schemes: dict[str, bool] = {}
303
+    formats: dict[str, bool] = {}
304
+    subcommands: set[str] = set()
305
+    extras: set[str] = set()
306
+    features: dict[str, bool] = {}
307
+    if len(paragraphs) < 2:  # pragma: no cover
308
+        return VersionOutputData(
309
+            derivation_schemes=schemes,
310
+            foreign_configuration_formats=formats,
311
+            subcommands=frozenset(subcommands),
312
+            extras=frozenset(extras),
313
+            features=features,
314
+        )
315
+    for line in paragraphs[1]:
316
+        line_type, _, value = line.partition(":")
317
+        if line_type == line:
318
+            continue
319
+        for item_ in re.split(r"(?:, *|.$)", value):
320
+            item = item_.strip()
321
+            if not item:
322
+                continue
323
+            if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS:
324
+                formats[item] = True
325
+            elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS:
326
+                formats[item] = False
327
+            elif line_type == KnownLineType.SUPPORTED_SCHEMES:
328
+                schemes[item] = True
329
+            elif line_type == KnownLineType.UNAVAILABLE_SCHEMES:
330
+                schemes[item] = False
331
+            elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS:
332
+                subcommands.add(item)
333
+            elif line_type == KnownLineType.ENABLED_EXTRAS:
334
+                extras.add(item)
335
+            elif line_type == KnownLineType.SUPPORTED_FEATURES:
336
+                features[item] = True
337
+            elif line_type == KnownLineType.UNAVAILABLE_FEATURES:
338
+                features[item] = False
339
+            else:
340
+                raise AssertionError(  # noqa: TRY003
341
+                    f"Unknown version info line type: {line_type!r}"  # noqa: EM102
342
+                )
343
+    return VersionOutputData(
344
+        derivation_schemes=schemes,
345
+        foreign_configuration_formats=formats,
346
+        subcommands=frozenset(subcommands),
347
+        extras=frozenset(extras),
348
+        features=features,
349
+    )
350
+
351
+
352
+class TestAllCLI:
353
+    """Tests uniformly for all command-line interfaces."""
354
+
355
+    @Parametrize.MASK_PROG_NAME
356
+    @Parametrize.MASK_VERSION
357
+    @Parametrize.VERSION_OUTPUT_DATA
358
+    def test_001_parse_version_output(
359
+        self,
360
+        version_output: str,
361
+        prog_name: str | None,
362
+        version: str | None,
363
+        mask_prog_name: bool,
364
+        mask_version: bool,
365
+        expected_parse: VersionOutputData,
366
+    ) -> None:
367
+        """The parsing machinery for expected version output data works."""
368
+        prog_name = None if mask_prog_name else prog_name
369
+        version = None if mask_version else version
370
+        assert (
371
+            parse_version_output(
372
+                version_output, prog_name=prog_name, version=version
373
+            )
374
+            == expected_parse
375
+        )
376
+
377
+    # TODO(the-13th-letter): Do we actually need this?  What should we
378
+    # check for?
379
+    def test_100_help_output(self) -> None:
380
+        """The top-level help text mentions subcommands.
381
+
382
+        TODO: Do we actually need this?  What should we check for?
383
+
384
+        """
385
+        runner = machinery.CliRunner(mix_stderr=False)
386
+        # TODO(the-13th-letter): Rewrite using parenthesized
387
+        # with-statements.
388
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
389
+        with contextlib.ExitStack() as stack:
390
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
391
+            stack.enter_context(
392
+                pytest_machinery.isolated_config(
393
+                    monkeypatch=monkeypatch,
394
+                    runner=runner,
395
+                )
396
+            )
397
+            result = runner.invoke(
398
+                cli.derivepassphrase, ["--help"], catch_exceptions=False
399
+            )
400
+        assert result.clean_exit(
401
+            empty_stderr=True, output="currently implemented subcommands"
402
+        ), "expected clean exit, and known help text"
403
+
404
+    # TODO(the-13th-letter): Do we actually need this?  What should we
405
+    # check for?
406
+    def test_101_help_output_export(
407
+        self,
408
+    ) -> None:
409
+        """The "export" subcommand help text mentions subcommands.
410
+
411
+        TODO: Do we actually need this?  What should we check for?
412
+
413
+        """
414
+        runner = machinery.CliRunner(mix_stderr=False)
415
+        # TODO(the-13th-letter): Rewrite using parenthesized
416
+        # with-statements.
417
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
418
+        with contextlib.ExitStack() as stack:
419
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
420
+            stack.enter_context(
421
+                pytest_machinery.isolated_config(
422
+                    monkeypatch=monkeypatch,
423
+                    runner=runner,
424
+                )
425
+            )
426
+            result = runner.invoke(
427
+                cli.derivepassphrase,
428
+                ["export", "--help"],
429
+                catch_exceptions=False,
430
+            )
431
+        assert result.clean_exit(
432
+            empty_stderr=True, output="only available subcommand"
433
+        ), "expected clean exit, and known help text"
434
+
435
+    # TODO(the-13th-letter): Do we actually need this?  What should we
436
+    # check for?
437
+    def test_102_help_output_export_vault(
438
+        self,
439
+    ) -> None:
440
+        """The "export vault" subcommand help text has known content.
441
+
442
+        TODO: Do we actually need this?  What should we check for?
443
+
444
+        """
445
+        runner = machinery.CliRunner(mix_stderr=False)
446
+        # TODO(the-13th-letter): Rewrite using parenthesized
447
+        # with-statements.
448
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
449
+        with contextlib.ExitStack() as stack:
450
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
451
+            stack.enter_context(
452
+                pytest_machinery.isolated_config(
453
+                    monkeypatch=monkeypatch,
454
+                    runner=runner,
455
+                )
456
+            )
457
+            result = runner.invoke(
458
+                cli.derivepassphrase,
459
+                ["export", "vault", "--help"],
460
+                catch_exceptions=False,
461
+            )
462
+        assert result.clean_exit(
463
+            empty_stderr=True, output="Export a vault-native configuration"
464
+        ), "expected clean exit, and known help text"
465
+
466
+    # TODO(the-13th-letter): Do we actually need this?  What should we
467
+    # check for?
468
+    def test_103_help_output_vault(
469
+        self,
470
+    ) -> None:
471
+        """The "vault" subcommand help text has known content.
472
+
473
+        TODO: Do we actually need this?  What should we check for?
474
+
475
+        """
476
+        runner = machinery.CliRunner(mix_stderr=False)
477
+        # TODO(the-13th-letter): Rewrite using parenthesized
478
+        # with-statements.
479
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
480
+        with contextlib.ExitStack() as stack:
481
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
482
+            stack.enter_context(
483
+                pytest_machinery.isolated_config(
484
+                    monkeypatch=monkeypatch,
485
+                    runner=runner,
486
+                )
487
+            )
488
+            result = runner.invoke(
489
+                cli.derivepassphrase,
490
+                ["vault", "--help"],
491
+                catch_exceptions=False,
492
+            )
493
+        assert result.clean_exit(
494
+            empty_stderr=True, output="Passphrase generation:\n"
495
+        ), "expected clean exit, and option groups in help text"
496
+        assert result.clean_exit(
497
+            empty_stderr=True, output="Use $VISUAL or $EDITOR to configure"
498
+        ), "expected clean exit, and option group epilog in help text"
499
+
500
+    @Parametrize.COMMAND_NON_EAGER_ARGUMENTS
501
+    @Parametrize.EAGER_ARGUMENTS
502
+    def test_200_eager_options(
503
+        self,
504
+        command: list[str],
505
+        arguments: list[str],
506
+        non_eager_arguments: list[str],
507
+    ) -> None:
508
+        """Eager options terminate option and argument processing."""
509
+        runner = machinery.CliRunner(mix_stderr=False)
510
+        # TODO(the-13th-letter): Rewrite using parenthesized
511
+        # with-statements.
512
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
513
+        with contextlib.ExitStack() as stack:
514
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
515
+            stack.enter_context(
516
+                pytest_machinery.isolated_config(
517
+                    monkeypatch=monkeypatch,
518
+                    runner=runner,
519
+                )
520
+            )
521
+            result = runner.invoke(
522
+                cli.derivepassphrase,
523
+                [*command, *arguments, *non_eager_arguments],
524
+                catch_exceptions=False,
525
+            )
526
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
527
+
528
+    @Parametrize.ISATTY
529
+    @Parametrize.COLORFUL_COMMAND_INPUT
530
+    def test_201_automatic_color_mode(
531
+        self,
532
+        isatty: bool,
533
+        command_line: list[str],
534
+        input: str | None,
535
+    ) -> None:
536
+        """Auto-detect if color should be used.
537
+
538
+        (The answer currently is always no. See the
539
+        [`conventional-configurable-text-styling` wishlist
540
+        entry](../wishlist/conventional-configurable-text-styling.md).)
541
+
542
+        """
543
+        color = False
544
+        runner = machinery.CliRunner(mix_stderr=False)
545
+        # TODO(the-13th-letter): Rewrite using parenthesized
546
+        # with-statements.
547
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
548
+        with contextlib.ExitStack() as stack:
549
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
550
+            stack.enter_context(
551
+                pytest_machinery.isolated_config(
552
+                    monkeypatch=monkeypatch,
553
+                    runner=runner,
554
+                )
555
+            )
556
+            result = runner.invoke(
557
+                cli.derivepassphrase,
558
+                command_line,
559
+                input=input,
560
+                catch_exceptions=False,
561
+                color=isatty,
562
+            )
563
+        assert (
564
+            not color
565
+            or "\x1b[0m" in result.stderr
566
+            or "\x1b[m" in result.stderr
567
+        ), "Expected color, but found no ANSI reset sequence"
568
+        assert color or "\x1b[" not in result.stderr, (
569
+            "Expected no color, but found an ANSI control sequence"
570
+        )
571
+
572
+    def test_202a_derivepassphrase_version_option_output(
573
+        self,
574
+    ) -> None:
575
+        """The version output states supported features.
576
+
577
+        The version output is parsed using [`parse_version_output`][].
578
+        Format examples can be found in
579
+        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
580
+        top-level `derivepassphrase` command, the output should contain
581
+        the known and supported derivation schemes, and a list of
582
+        subcommands.
583
+
584
+        """
585
+        runner = machinery.CliRunner(mix_stderr=False)
586
+        # TODO(the-13th-letter): Rewrite using parenthesized
587
+        # with-statements.
588
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
589
+        with contextlib.ExitStack() as stack:
590
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
591
+            stack.enter_context(
592
+                pytest_machinery.isolated_config(
593
+                    monkeypatch=monkeypatch,
594
+                    runner=runner,
595
+                )
596
+            )
597
+            result = runner.invoke(
598
+                cli.derivepassphrase,
599
+                ["--version"],
600
+                catch_exceptions=False,
601
+            )
602
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
603
+        assert result.stdout.strip(), "expected version output"
604
+        version_data = parse_version_output(result.stdout)
605
+        actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True)
606
+        subcommands = set(_types.Subcommand)
607
+        assert version_data.derivation_schemes == actually_known_schemes
608
+        assert not version_data.foreign_configuration_formats
609
+        assert version_data.subcommands == subcommands
610
+        assert not version_data.features
611
+        assert not version_data.extras
612
+
613
+    def test_202b_export_version_option_output(
614
+        self,
615
+    ) -> None:
616
+        """The version output states supported features.
617
+
618
+        The version output is parsed using [`parse_version_output`][].
619
+        Format examples can be found in
620
+        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
621
+        `export` command, the output should contain the known foreign
622
+        configuration formats (but not marked as supported), and a list
623
+        of subcommands.
624
+
625
+        """
626
+        runner = machinery.CliRunner(mix_stderr=False)
627
+        # TODO(the-13th-letter): Rewrite using parenthesized
628
+        # with-statements.
629
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
630
+        with contextlib.ExitStack() as stack:
631
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
632
+            stack.enter_context(
633
+                pytest_machinery.isolated_config(
634
+                    monkeypatch=monkeypatch,
635
+                    runner=runner,
636
+                )
637
+            )
638
+            result = runner.invoke(
639
+                cli.derivepassphrase,
640
+                ["export", "--version"],
641
+                catch_exceptions=False,
642
+            )
643
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
644
+        assert result.stdout.strip(), "expected version output"
645
+        version_data = parse_version_output(result.stdout)
646
+        actually_known_formats: dict[str, bool] = {
647
+            _types.ForeignConfigurationFormat.VAULT_STOREROOM: False,
648
+            _types.ForeignConfigurationFormat.VAULT_V02: False,
649
+            _types.ForeignConfigurationFormat.VAULT_V03: False,
650
+        }
651
+        subcommands = set(_types.ExportSubcommand)
652
+        assert not version_data.derivation_schemes
653
+        assert (
654
+            version_data.foreign_configuration_formats
655
+            == actually_known_formats
656
+        )
657
+        assert version_data.subcommands == subcommands
658
+        assert not version_data.features
659
+        assert not version_data.extras
660
+
661
+    def test_202c_export_vault_version_option_output(
662
+        self,
663
+    ) -> None:
664
+        """The version output states supported features.
665
+
666
+        The version output is parsed using [`parse_version_output`][].
667
+        Format examples can be found in
668
+        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
669
+        `export vault` subcommand, the output should contain the
670
+        vault-specific subset of the known or supported foreign
671
+        configuration formats, and a list of available PEP 508 extras.
672
+
673
+        """
674
+        runner = machinery.CliRunner(mix_stderr=False)
675
+        # TODO(the-13th-letter): Rewrite using parenthesized
676
+        # with-statements.
677
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
678
+        with contextlib.ExitStack() as stack:
679
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
680
+            stack.enter_context(
681
+                pytest_machinery.isolated_config(
682
+                    monkeypatch=monkeypatch,
683
+                    runner=runner,
684
+                )
685
+            )
686
+            result = runner.invoke(
687
+                cli.derivepassphrase,
688
+                ["export", "vault", "--version"],
689
+                catch_exceptions=False,
690
+            )
691
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
692
+        assert result.stdout.strip(), "expected version output"
693
+        version_data = parse_version_output(result.stdout)
694
+        actually_known_formats: dict[str, bool] = {}
695
+        actually_enabled_extras: set[str] = set()
696
+        with contextlib.suppress(ModuleNotFoundError):
697
+            from derivepassphrase.exporter import storeroom, vault_native  # noqa: I001,PLC0415
698
+
699
+            actually_known_formats.update({
700
+                _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED,
701
+                _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED,
702
+                _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED,
703
+            })
704
+        with contextlib.suppress(ModuleNotFoundError):
705
+            import cryptography  # noqa: F401,PLC0415
706
+
707
+            actually_enabled_extras.add(_types.PEP508Extra.EXPORT)
708
+        assert not version_data.derivation_schemes
709
+        assert (
710
+            version_data.foreign_configuration_formats
711
+            == actually_known_formats
712
+        )
713
+        assert not version_data.subcommands
714
+        assert not version_data.features
715
+        assert version_data.extras == actually_enabled_extras
716
+
717
+    def test_202d_vault_version_option_output(
718
+        self,
719
+    ) -> None:
720
+        """The version output states supported features.
721
+
722
+        The version output is parsed using [`parse_version_output`][].
723
+        Format examples can be found in
724
+        [`Parametrize.VERSION_OUTPUT_DATA`][].  Specifically, for the
725
+        vault command, the output should not contain anything beyond the
726
+        first paragraph.
727
+
728
+        """
729
+        runner = machinery.CliRunner(mix_stderr=False)
730
+        # TODO(the-13th-letter): Rewrite using parenthesized
731
+        # with-statements.
732
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
733
+        with contextlib.ExitStack() as stack:
734
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
735
+            stack.enter_context(
736
+                pytest_machinery.isolated_config(
737
+                    monkeypatch=monkeypatch,
738
+                    runner=runner,
739
+                )
740
+            )
741
+            result = runner.invoke(
742
+                cli.derivepassphrase,
743
+                ["vault", "--version"],
744
+                catch_exceptions=False,
745
+            )
746
+        assert result.clean_exit(empty_stderr=True), "expected clean exit"
747
+        assert result.stdout.strip(), "expected version output"
748
+        version_data = parse_version_output(result.stdout)
749
+
750
+        ssh_key_supported = True
751
+
752
+        def react_to_notimplementederror(
753
+            _exc: BaseException,
754
+        ) -> None:  # pragma: no cover[unused]
755
+            nonlocal ssh_key_supported
756
+            ssh_key_supported = False
757
+
758
+        with exceptiongroup.catch({  # noqa: SIM117
759
+            NotImplementedError: react_to_notimplementederror,
760
+            Exception: lambda *_args: None,
761
+        }):
762
+            with ssh_agent.SSHAgentClient.ensure_agent_subcontext():
763
+                pass
764
+        features: dict[str, bool] = {
765
+            _types.Feature.SSH_KEY: ssh_key_supported,
766
+        }
767
+        assert not version_data.derivation_schemes
768
+        assert not version_data.foreign_configuration_formats
769
+        assert not version_data.subcommands
770
+        assert version_data.features == features
771
+        assert not version_data.extras
... ...
@@ -0,0 +1,835 @@
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 json
9
+import types
10
+from typing import TYPE_CHECKING
11
+
12
+import click.testing
13
+import pytest
14
+from typing_extensions import Any
15
+
16
+from derivepassphrase import _types, cli
17
+from derivepassphrase._internals import cli_helpers
18
+from tests import data, machinery
19
+from tests.machinery import pytest as pytest_machinery
20
+
21
+if TYPE_CHECKING:
22
+    from collections.abc import Callable, Sequence
23
+    from collections.abc import Set as AbstractSet
24
+    from typing import NoReturn
25
+
26
+    from typing_extensions import Literal
27
+
28
+DUMMY_SERVICE = data.DUMMY_SERVICE
29
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
30
+
31
+
32
+def bash_format(item: click.shell_completion.CompletionItem) -> str:
33
+    """A formatter for `bash`-style shell completion items.
34
+
35
+    The format is `type,value`, and is dictated by [`click`][].
36
+
37
+    """
38
+    type, value = (  # noqa: A001
39
+        item.type,
40
+        item.value,
41
+    )
42
+    return f"{type},{value}"
43
+
44
+
45
+def fish_format(item: click.shell_completion.CompletionItem) -> str:
46
+    r"""A formatter for `fish`-style shell completion items.
47
+
48
+    The format is `type,value<tab>help`, and is dictated by [`click`][].
49
+
50
+    """
51
+    type, value, help = (  # noqa: A001
52
+        item.type,
53
+        item.value,
54
+        item.help,
55
+    )
56
+    return f"{type},{value}\t{help}" if help else f"{type},{value}"
57
+
58
+
59
+def zsh_format(item: click.shell_completion.CompletionItem) -> str:
60
+    r"""A formatter for `zsh`-style shell completion items.
61
+
62
+    The format is `type<newline>value<newline>help<newline>`, and is
63
+    dictated by [`click`][].  Upstream `click` currently (v8.2.0) does
64
+    not deal with colons in the value correctly when the help text is
65
+    non-degenerate.  Our formatter here does, provided the upstream
66
+    `zsh` completion script is used; see the
67
+    [`cli_machinery.ZshComplete`][] class.  A request is underway to
68
+    merge this change into upstream `click`; see
69
+    [`pallets/click#2846`][PR2846].
70
+
71
+    [PR2846]: https://github.com/pallets/click/pull/2846
72
+
73
+    """
74
+    empty_help = "_"
75
+    help_, value = (
76
+        (item.help, item.value.replace(":", r"\:"))
77
+        if item.help and item.help == empty_help
78
+        else (empty_help, item.value)
79
+    )
80
+    return f"{item.type}\n{value}\n{help_}"
81
+
82
+
83
+def completion_item(
84
+    item: str | click.shell_completion.CompletionItem,
85
+) -> click.shell_completion.CompletionItem:
86
+    """Convert a string to a completion item, if necessary."""
87
+    return (
88
+        click.shell_completion.CompletionItem(item, type="plain")
89
+        if isinstance(item, str)
90
+        else item
91
+    )
92
+
93
+
94
+def assertable_item(
95
+    item: str | click.shell_completion.CompletionItem,
96
+) -> tuple[str, Any, str | None]:
97
+    """Convert a completion item into a pretty-printable item.
98
+
99
+    Intended to make completion items introspectable in pytest's
100
+    `assert` output.
101
+
102
+    """
103
+    item = completion_item(item)
104
+    return (item.type, item.value, item.help)
105
+
106
+
107
+class Parametrize(types.SimpleNamespace):
108
+    """Common test parametrizations."""
109
+
110
+    COMPLETABLE_PATH_ARGUMENT = pytest.mark.parametrize(
111
+        "command_prefix",
112
+        [
113
+            pytest.param(
114
+                ("export", "vault"),
115
+                id="derivepassphrase-export-vault",
116
+            ),
117
+            pytest.param(
118
+                ("vault", "--export"),
119
+                id="derivepassphrase-vault--export",
120
+            ),
121
+            pytest.param(
122
+                ("vault", "--import"),
123
+                id="derivepassphrase-vault--import",
124
+            ),
125
+        ],
126
+    )
127
+    COMPLETABLE_OPTIONS = pytest.mark.parametrize(
128
+        ["command_prefix", "incomplete", "completions"],
129
+        [
130
+            pytest.param(
131
+                (),
132
+                "-",
133
+                frozenset({
134
+                    "--help",
135
+                    "-h",
136
+                    "--version",
137
+                    "--debug",
138
+                    "--verbose",
139
+                    "-v",
140
+                    "--quiet",
141
+                    "-q",
142
+                }),
143
+                id="derivepassphrase",
144
+            ),
145
+            pytest.param(
146
+                ("export",),
147
+                "-",
148
+                frozenset({
149
+                    "--help",
150
+                    "-h",
151
+                    "--version",
152
+                    "--debug",
153
+                    "--verbose",
154
+                    "-v",
155
+                    "--quiet",
156
+                    "-q",
157
+                }),
158
+                id="derivepassphrase-export",
159
+            ),
160
+            pytest.param(
161
+                ("export", "vault"),
162
+                "-",
163
+                frozenset({
164
+                    "--help",
165
+                    "-h",
166
+                    "--version",
167
+                    "--debug",
168
+                    "--verbose",
169
+                    "-v",
170
+                    "--quiet",
171
+                    "-q",
172
+                    "--format",
173
+                    "-f",
174
+                    "--key",
175
+                    "-k",
176
+                }),
177
+                id="derivepassphrase-export-vault",
178
+            ),
179
+            pytest.param(
180
+                ("vault",),
181
+                "-",
182
+                frozenset({
183
+                    "--help",
184
+                    "-h",
185
+                    "--version",
186
+                    "--debug",
187
+                    "--verbose",
188
+                    "-v",
189
+                    "--quiet",
190
+                    "-q",
191
+                    "--phrase",
192
+                    "-p",
193
+                    "--key",
194
+                    "-k",
195
+                    "--length",
196
+                    "-l",
197
+                    "--repeat",
198
+                    "-r",
199
+                    "--upper",
200
+                    "--lower",
201
+                    "--number",
202
+                    "--space",
203
+                    "--dash",
204
+                    "--symbol",
205
+                    "--config",
206
+                    "-c",
207
+                    "--notes",
208
+                    "-n",
209
+                    "--delete",
210
+                    "-x",
211
+                    "--delete-globals",
212
+                    "--clear",
213
+                    "-X",
214
+                    "--export",
215
+                    "-e",
216
+                    "--import",
217
+                    "-i",
218
+                    "--overwrite-existing",
219
+                    "--merge-existing",
220
+                    "--unset",
221
+                    "--export-as",
222
+                    "--modern-editor-interface",
223
+                    "--vault-legacy-editor-interface",
224
+                    "--print-notes-before",
225
+                    "--print-notes-after",
226
+                }),
227
+                id="derivepassphrase-vault",
228
+            ),
229
+        ],
230
+    )
231
+    COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize(
232
+        ["command_prefix", "incomplete", "completions"],
233
+        [
234
+            pytest.param(
235
+                (),
236
+                "",
237
+                frozenset({"export", "vault"}),
238
+                id="derivepassphrase",
239
+            ),
240
+            pytest.param(
241
+                ("export",),
242
+                "",
243
+                frozenset({"vault"}),
244
+                id="derivepassphrase-export",
245
+            ),
246
+        ],
247
+    )
248
+    COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize(
249
+        ["config", "comp_func", "args", "incomplete", "results"],
250
+        [
251
+            pytest.param(
252
+                {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
253
+                cli_helpers.shell_complete_service,
254
+                ["vault"],
255
+                "",
256
+                [DUMMY_SERVICE],
257
+                id="base_config-service",
258
+            ),
259
+            pytest.param(
260
+                {"services": {}},
261
+                cli_helpers.shell_complete_service,
262
+                ["vault"],
263
+                "",
264
+                [],
265
+                id="empty_config-service",
266
+            ),
267
+            pytest.param(
268
+                {
269
+                    "services": {
270
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
271
+                        "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(),
272
+                    }
273
+                },
274
+                cli_helpers.shell_complete_service,
275
+                ["vault"],
276
+                "",
277
+                [DUMMY_SERVICE],
278
+                id="incompletable_newline_config-service",
279
+            ),
280
+            pytest.param(
281
+                {
282
+                    "services": {
283
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
284
+                        "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(),
285
+                    }
286
+                },
287
+                cli_helpers.shell_complete_service,
288
+                ["vault"],
289
+                "",
290
+                [DUMMY_SERVICE],
291
+                id="incompletable_backspace_config-service",
292
+            ),
293
+            pytest.param(
294
+                {
295
+                    "services": {
296
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
297
+                        "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(),
298
+                    }
299
+                },
300
+                cli_helpers.shell_complete_service,
301
+                ["vault"],
302
+                "",
303
+                sorted([DUMMY_SERVICE, "colon:in:name"]),
304
+                id="brittle_colon_config-service",
305
+            ),
306
+            pytest.param(
307
+                {
308
+                    "services": {
309
+                        DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
310
+                        "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(),
311
+                        "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(),
312
+                        "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(),
313
+                        "nul\x00in\x00name": DUMMY_CONFIG_SETTINGS.copy(),
314
+                        "del\x7fin\x7fname": DUMMY_CONFIG_SETTINGS.copy(),
315
+                    }
316
+                },
317
+                cli_helpers.shell_complete_service,
318
+                ["vault"],
319
+                "",
320
+                sorted([DUMMY_SERVICE, "colon:in:name"]),
321
+                id="brittle_incompletable_multi_config-service",
322
+            ),
323
+            pytest.param(
324
+                {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
325
+                cli_helpers.shell_complete_path,
326
+                ["vault", "--import"],
327
+                "",
328
+                [click.shell_completion.CompletionItem("", type="file")],
329
+                id="base_config-path",
330
+            ),
331
+            pytest.param(
332
+                {"services": {}},
333
+                cli_helpers.shell_complete_path,
334
+                ["vault", "--import"],
335
+                "",
336
+                [click.shell_completion.CompletionItem("", type="file")],
337
+                id="empty_config-path",
338
+            ),
339
+        ],
340
+    )
341
+    COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize(
342
+        ["config", "incomplete", "completions"],
343
+        [
344
+            pytest.param(
345
+                {"services": {}},
346
+                "",
347
+                frozenset(),
348
+                id="no_services",
349
+            ),
350
+            pytest.param(
351
+                {"services": {}},
352
+                "partial",
353
+                frozenset(),
354
+                id="no_services_partial",
355
+            ),
356
+            pytest.param(
357
+                {"services": {DUMMY_SERVICE: {"length": 10}}},
358
+                "",
359
+                frozenset({DUMMY_SERVICE}),
360
+                id="one_service",
361
+            ),
362
+            pytest.param(
363
+                {"services": {DUMMY_SERVICE: {"length": 10}}},
364
+                DUMMY_SERVICE[:4],
365
+                frozenset({DUMMY_SERVICE}),
366
+                id="one_service_partial",
367
+            ),
368
+            pytest.param(
369
+                {"services": {DUMMY_SERVICE: {"length": 10}}},
370
+                DUMMY_SERVICE[-4:],
371
+                frozenset(),
372
+                id="one_service_partial_miss",
373
+            ),
374
+        ],
375
+    )
376
+    SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize(
377
+        ["config", "key", "incomplete", "completions"],
378
+        [
379
+            pytest.param(
380
+                {
381
+                    "services": {
382
+                        DUMMY_SERVICE: {"length": 10},
383
+                        "newline\nin\nname": {"length": 10},
384
+                    },
385
+                },
386
+                "newline\nin\nname",
387
+                "",
388
+                frozenset({DUMMY_SERVICE}),
389
+                id="newline",
390
+            ),
391
+            pytest.param(
392
+                {
393
+                    "services": {
394
+                        DUMMY_SERVICE: {"length": 10},
395
+                        "newline\nin\nname": {"length": 10},
396
+                    },
397
+                },
398
+                "newline\nin\nname",
399
+                "serv",
400
+                frozenset({DUMMY_SERVICE}),
401
+                id="newline_partial_other",
402
+            ),
403
+            pytest.param(
404
+                {
405
+                    "services": {
406
+                        DUMMY_SERVICE: {"length": 10},
407
+                        "newline\nin\nname": {"length": 10},
408
+                    },
409
+                },
410
+                "newline\nin\nname",
411
+                "newline",
412
+                frozenset({}),
413
+                id="newline_partial_specific",
414
+            ),
415
+            pytest.param(
416
+                {
417
+                    "services": {
418
+                        DUMMY_SERVICE: {"length": 10},
419
+                        "nul\x00in\x00name": {"length": 10},
420
+                    },
421
+                },
422
+                "nul\x00in\x00name",
423
+                "",
424
+                frozenset({DUMMY_SERVICE}),
425
+                id="nul",
426
+            ),
427
+            pytest.param(
428
+                {
429
+                    "services": {
430
+                        DUMMY_SERVICE: {"length": 10},
431
+                        "nul\x00in\x00name": {"length": 10},
432
+                    },
433
+                },
434
+                "nul\x00in\x00name",
435
+                "serv",
436
+                frozenset({DUMMY_SERVICE}),
437
+                id="nul_partial_other",
438
+            ),
439
+            pytest.param(
440
+                {
441
+                    "services": {
442
+                        DUMMY_SERVICE: {"length": 10},
443
+                        "nul\x00in\x00name": {"length": 10},
444
+                    },
445
+                },
446
+                "nul\x00in\x00name",
447
+                "nul",
448
+                frozenset({}),
449
+                id="nul_partial_specific",
450
+            ),
451
+            pytest.param(
452
+                {
453
+                    "services": {
454
+                        DUMMY_SERVICE: {"length": 10},
455
+                        "backspace\bin\bname": {"length": 10},
456
+                    },
457
+                },
458
+                "backspace\bin\bname",
459
+                "",
460
+                frozenset({DUMMY_SERVICE}),
461
+                id="backspace",
462
+            ),
463
+            pytest.param(
464
+                {
465
+                    "services": {
466
+                        DUMMY_SERVICE: {"length": 10},
467
+                        "backspace\bin\bname": {"length": 10},
468
+                    },
469
+                },
470
+                "backspace\bin\bname",
471
+                "serv",
472
+                frozenset({DUMMY_SERVICE}),
473
+                id="backspace_partial_other",
474
+            ),
475
+            pytest.param(
476
+                {
477
+                    "services": {
478
+                        DUMMY_SERVICE: {"length": 10},
479
+                        "backspace\bin\bname": {"length": 10},
480
+                    },
481
+                },
482
+                "backspace\bin\bname",
483
+                "back",
484
+                frozenset({}),
485
+                id="backspace_partial_specific",
486
+            ),
487
+            pytest.param(
488
+                {
489
+                    "services": {
490
+                        DUMMY_SERVICE: {"length": 10},
491
+                        "del\x7fin\x7fname": {"length": 10},
492
+                    },
493
+                },
494
+                "del\x7fin\x7fname",
495
+                "",
496
+                frozenset({DUMMY_SERVICE}),
497
+                id="del",
498
+            ),
499
+            pytest.param(
500
+                {
501
+                    "services": {
502
+                        DUMMY_SERVICE: {"length": 10},
503
+                        "del\x7fin\x7fname": {"length": 10},
504
+                    },
505
+                },
506
+                "del\x7fin\x7fname",
507
+                "serv",
508
+                frozenset({DUMMY_SERVICE}),
509
+                id="del_partial_other",
510
+            ),
511
+            pytest.param(
512
+                {
513
+                    "services": {
514
+                        DUMMY_SERVICE: {"length": 10},
515
+                        "del\x7fin\x7fname": {"length": 10},
516
+                    },
517
+                },
518
+                "del\x7fin\x7fname",
519
+                "del",
520
+                frozenset({}),
521
+                id="del_partial_specific",
522
+            ),
523
+        ],
524
+    )
525
+    SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize(
526
+        "exc_type", [RuntimeError, KeyError, ValueError]
527
+    )
528
+    INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"])
529
+    CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"])
530
+    COMPLETABLE_ITEMS = pytest.mark.parametrize(
531
+        ["partial", "is_completable"],
532
+        [
533
+            ("", True),
534
+            (DUMMY_SERVICE, True),
535
+            ("a\bn", False),
536
+            ("\b", False),
537
+            ("\x00", False),
538
+            ("\x20", True),
539
+            ("\x7f", False),
540
+            ("service with spaces", True),
541
+            ("service\nwith\nnewlines", False),
542
+        ],
543
+    )
544
+    SHELL_FORMATTER = pytest.mark.parametrize(
545
+        ["shell", "format_func"],
546
+        [
547
+            pytest.param("bash", bash_format, id="bash"),
548
+            pytest.param("fish", fish_format, id="fish"),
549
+            pytest.param("zsh", zsh_format, id="zsh"),
550
+        ],
551
+    )
552
+
553
+
554
+class TestShellCompletion:
555
+    """Tests for the shell completion machinery."""
556
+
557
+    class Completions:
558
+        """A deferred completion call."""
559
+
560
+        def __init__(
561
+            self,
562
+            args: Sequence[str],
563
+            incomplete: str,
564
+        ) -> None:
565
+            """Initialize the object.
566
+
567
+            Args:
568
+                args:
569
+                    The sequence of complete command-line arguments.
570
+                incomplete:
571
+                    The final, incomplete, partial argument.
572
+
573
+            """
574
+            self.args = tuple(args)
575
+            self.incomplete = incomplete
576
+
577
+        def __call__(self) -> Sequence[click.shell_completion.CompletionItem]:
578
+            """Return the completion items."""
579
+            args = list(self.args)
580
+            completion = click.shell_completion.ShellComplete(
581
+                cli=cli.derivepassphrase,
582
+                ctx_args={},
583
+                prog_name="derivepassphrase",
584
+                complete_var="_DERIVEPASSPHRASE_COMPLETE",
585
+            )
586
+            return completion.get_completions(args, self.incomplete)
587
+
588
+        def get_words(self) -> Sequence[str]:
589
+            """Return the completion items' values, as a sequence."""
590
+            return tuple(c.value for c in self())
591
+
592
+    @Parametrize.COMPLETABLE_ITEMS
593
+    def test_100_is_completable_item(
594
+        self,
595
+        partial: str,
596
+        is_completable: bool,
597
+    ) -> None:
598
+        """Our `_is_completable_item` predicate for service names works."""
599
+        assert cli_helpers.is_completable_item(partial) == is_completable
600
+
601
+    @Parametrize.COMPLETABLE_OPTIONS
602
+    def test_200_options(
603
+        self,
604
+        command_prefix: Sequence[str],
605
+        incomplete: str,
606
+        completions: AbstractSet[str],
607
+    ) -> None:
608
+        """Our completion machinery works for all commands' options."""
609
+        comp = self.Completions(command_prefix, incomplete)
610
+        assert frozenset(comp.get_words()) == completions
611
+
612
+    @Parametrize.COMPLETABLE_SUBCOMMANDS
613
+    def test_201_subcommands(
614
+        self,
615
+        command_prefix: Sequence[str],
616
+        incomplete: str,
617
+        completions: AbstractSet[str],
618
+    ) -> None:
619
+        """Our completion machinery works for all commands' subcommands."""
620
+        comp = self.Completions(command_prefix, incomplete)
621
+        assert frozenset(comp.get_words()) == completions
622
+
623
+    @Parametrize.COMPLETABLE_PATH_ARGUMENT
624
+    @Parametrize.INCOMPLETE
625
+    def test_202_paths(
626
+        self,
627
+        command_prefix: Sequence[str],
628
+        incomplete: str,
629
+    ) -> None:
630
+        """Our completion machinery works for all commands' paths."""
631
+        file = click.shell_completion.CompletionItem("", type="file")
632
+        completions = frozenset({(file.type, file.value, file.help)})
633
+        comp = self.Completions(command_prefix, incomplete)
634
+        assert (
635
+            frozenset((x.type, x.value, x.help) for x in comp()) == completions
636
+        )
637
+
638
+    @Parametrize.COMPLETABLE_SERVICE_NAMES
639
+    def test_203_service_names(
640
+        self,
641
+        config: _types.VaultConfig,
642
+        incomplete: str,
643
+        completions: AbstractSet[str],
644
+    ) -> None:
645
+        """Our completion machinery works for vault service names."""
646
+        runner = machinery.CliRunner(mix_stderr=False)
647
+        # TODO(the-13th-letter): Rewrite using parenthesized
648
+        # with-statements.
649
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
650
+        with contextlib.ExitStack() as stack:
651
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
652
+            stack.enter_context(
653
+                pytest_machinery.isolated_vault_config(
654
+                    monkeypatch=monkeypatch,
655
+                    runner=runner,
656
+                    vault_config=config,
657
+                )
658
+            )
659
+            comp = self.Completions(["vault"], incomplete)
660
+            assert frozenset(comp.get_words()) == completions
661
+
662
+    @Parametrize.SHELL_FORMATTER
663
+    @Parametrize.COMPLETION_FUNCTION_INPUTS
664
+    def test_300_shell_completion_formatting(
665
+        self,
666
+        shell: str,
667
+        format_func: Callable[[click.shell_completion.CompletionItem], str],
668
+        config: _types.VaultConfig,
669
+        comp_func: Callable[
670
+            [click.Context, click.Parameter, str],
671
+            list[str | click.shell_completion.CompletionItem],
672
+        ],
673
+        args: list[str],
674
+        incomplete: str,
675
+        results: list[str | click.shell_completion.CompletionItem],
676
+    ) -> None:
677
+        """Custom completion functions work for all shells."""
678
+        runner = machinery.CliRunner(mix_stderr=False)
679
+        # TODO(the-13th-letter): Rewrite using parenthesized
680
+        # with-statements.
681
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
682
+        with contextlib.ExitStack() as stack:
683
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
684
+            stack.enter_context(
685
+                pytest_machinery.isolated_vault_config(
686
+                    monkeypatch=monkeypatch,
687
+                    runner=runner,
688
+                    vault_config=config,
689
+                )
690
+            )
691
+            expected_items = [assertable_item(item) for item in results]
692
+            expected_string = "\n".join(
693
+                format_func(completion_item(item)) for item in results
694
+            )
695
+            manual_raw_items = comp_func(
696
+                click.Context(cli.derivepassphrase),
697
+                click.Argument(["sample_parameter"]),
698
+                incomplete,
699
+            )
700
+            manual_items = [assertable_item(item) for item in manual_raw_items]
701
+            manual_string = "\n".join(
702
+                format_func(completion_item(item)) for item in manual_raw_items
703
+            )
704
+            assert manual_items == expected_items
705
+            assert manual_string == expected_string
706
+            comp_class = click.shell_completion.get_completion_class(shell)
707
+            assert comp_class is not None
708
+            comp = comp_class(
709
+                cli.derivepassphrase,
710
+                {},
711
+                "derivepassphrase",
712
+                "_DERIVEPASSPHRASE_COMPLETE",
713
+            )
714
+            monkeypatch.setattr(
715
+                comp,
716
+                "get_completion_args",
717
+                lambda *_a, **_kw: (args, incomplete),
718
+            )
719
+            actual_raw_items = comp.get_completions(
720
+                *comp.get_completion_args()
721
+            )
722
+            actual_items = [assertable_item(item) for item in actual_raw_items]
723
+            actual_string = comp.complete()
724
+            assert actual_items == expected_items
725
+            assert actual_string == expected_string
726
+
727
+    @Parametrize.CONFIG_SETTING_MODE
728
+    @Parametrize.SERVICE_NAME_COMPLETION_INPUTS
729
+    def test_400_incompletable_service_names(
730
+        self,
731
+        caplog: pytest.LogCaptureFixture,
732
+        mode: Literal["config", "import"],
733
+        config: _types.VaultConfig,
734
+        key: str,
735
+        incomplete: str,
736
+        completions: AbstractSet[str],
737
+    ) -> None:
738
+        """Completion skips incompletable items."""
739
+        vault_config = config if mode == "config" else {"services": {}}
740
+        runner = machinery.CliRunner(mix_stderr=False)
741
+        # TODO(the-13th-letter): Rewrite using parenthesized
742
+        # with-statements.
743
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
744
+        with contextlib.ExitStack() as stack:
745
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
746
+            stack.enter_context(
747
+                pytest_machinery.isolated_vault_config(
748
+                    monkeypatch=monkeypatch,
749
+                    runner=runner,
750
+                    vault_config=vault_config,
751
+                )
752
+            )
753
+            if mode == "config":
754
+                result = runner.invoke(
755
+                    cli.derivepassphrase_vault,
756
+                    ["--config", "--length=10", "--", key],
757
+                    catch_exceptions=False,
758
+                )
759
+            else:
760
+                result = runner.invoke(
761
+                    cli.derivepassphrase_vault,
762
+                    ["--import", "-"],
763
+                    catch_exceptions=False,
764
+                    input=json.dumps(config),
765
+                )
766
+            assert result.clean_exit(), "expected clean exit"
767
+            assert machinery.warning_emitted(
768
+                "contains an ASCII control character", caplog.record_tuples
769
+            ), "expected known warning message in stderr"
770
+            assert machinery.warning_emitted(
771
+                "not be available for completion", caplog.record_tuples
772
+            ), "expected known warning message in stderr"
773
+            assert cli_helpers.load_config() == config
774
+            comp = self.Completions(["vault"], incomplete)
775
+            assert frozenset(comp.get_words()) == completions
776
+
777
+    def test_410a_service_name_exceptions_not_found(
778
+        self,
779
+    ) -> None:
780
+        """Service name completion quietly fails on missing configuration."""
781
+        runner = machinery.CliRunner(mix_stderr=False)
782
+        # TODO(the-13th-letter): Rewrite using parenthesized
783
+        # with-statements.
784
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
785
+        with contextlib.ExitStack() as stack:
786
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
787
+            stack.enter_context(
788
+                pytest_machinery.isolated_vault_config(
789
+                    monkeypatch=monkeypatch,
790
+                    runner=runner,
791
+                    vault_config={
792
+                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
793
+                    },
794
+                )
795
+            )
796
+            cli_helpers.config_filename(subsystem="vault").unlink(
797
+                missing_ok=True
798
+            )
799
+            assert not cli_helpers.shell_complete_service(
800
+                click.Context(cli.derivepassphrase),
801
+                click.Argument(["some_parameter"]),
802
+                "",
803
+            )
804
+
805
+    @Parametrize.SERVICE_NAME_EXCEPTIONS
806
+    def test_410b_service_name_exceptions_custom_error(
807
+        self,
808
+        exc_type: type[Exception],
809
+    ) -> None:
810
+        """Service name completion quietly fails on configuration errors."""
811
+        runner = machinery.CliRunner(mix_stderr=False)
812
+        # TODO(the-13th-letter): Rewrite using parenthesized
813
+        # with-statements.
814
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
815
+        with contextlib.ExitStack() as stack:
816
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
817
+            stack.enter_context(
818
+                pytest_machinery.isolated_vault_config(
819
+                    monkeypatch=monkeypatch,
820
+                    runner=runner,
821
+                    vault_config={
822
+                        "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}
823
+                    },
824
+                )
825
+            )
826
+
827
+            def raiser(*_a: Any, **_kw: Any) -> NoReturn:
828
+                raise exc_type("just being difficult")  # noqa: EM101,TRY003
829
+
830
+            monkeypatch.setattr(cli_helpers, "load_config", raiser)
831
+            assert not cli_helpers.shell_complete_service(
832
+                click.Context(cli.derivepassphrase),
833
+                click.Argument(["some_parameter"]),
834
+                "",
835
+            )
... ...
@@ -0,0 +1,428 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+# TODO(the-13th-letter): Remove this module in v1.0.
6
+# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0
7
+
8
+from __future__ import annotations
9
+
10
+import contextlib
11
+import errno
12
+import json
13
+import logging
14
+import os
15
+import pathlib
16
+
17
+import click.testing
18
+import pytest
19
+from typing_extensions import Any
20
+
21
+from derivepassphrase import cli, vault
22
+from derivepassphrase._internals import (
23
+    cli_helpers,
24
+)
25
+from tests import data, machinery, test_derivepassphrase_cli
26
+from tests.data import callables
27
+from tests.machinery import pytest as pytest_machinery
28
+from tests.test_derivepassphrase_cli import test_utils
29
+
30
+DUMMY_SERVICE = data.DUMMY_SERVICE
31
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
32
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
33
+
34
+
35
+class Parametrize(
36
+    test_derivepassphrase_cli.Parametrize, test_utils.Parametrize
37
+):
38
+    """Common test parametrizations."""
39
+
40
+    BAD_CONFIGS = pytest.mark.parametrize(
41
+        "config",
42
+        [
43
+            {"global": "", "services": {}},
44
+            {"global": 0, "services": {}},
45
+            {
46
+                "global": {"phrase": "abc"},
47
+                "services": False,
48
+            },
49
+            {
50
+                "global": {"phrase": "abc"},
51
+                "services": True,
52
+            },
53
+            {
54
+                "global": {"phrase": "abc"},
55
+                "services": None,
56
+            },
57
+        ],
58
+    )
59
+
60
+
61
+class TestCLITransition:
62
+    """Transition tests for the command-line interface up to v1.0."""
63
+
64
+    @Parametrize.BASE_CONFIG_VARIATIONS
65
+    def test_110_load_config_backup(
66
+        self,
67
+        config: Any,
68
+    ) -> None:
69
+        """Loading the old settings file works."""
70
+        runner = machinery.CliRunner(mix_stderr=False)
71
+        # TODO(the-13th-letter): Rewrite using parenthesized
72
+        # with-statements.
73
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
74
+        with contextlib.ExitStack() as stack:
75
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
76
+            stack.enter_context(
77
+                pytest_machinery.isolated_config(
78
+                    monkeypatch=monkeypatch,
79
+                    runner=runner,
80
+                )
81
+            )
82
+            cli_helpers.config_filename(
83
+                subsystem="old settings.json"
84
+            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
85
+            assert cli_helpers.migrate_and_load_old_config()[0] == config
86
+
87
+    @Parametrize.BASE_CONFIG_VARIATIONS
88
+    def test_111_migrate_config(
89
+        self,
90
+        config: Any,
91
+    ) -> None:
92
+        """Migrating the old settings file works."""
93
+        runner = machinery.CliRunner(mix_stderr=False)
94
+        # TODO(the-13th-letter): Rewrite using parenthesized
95
+        # with-statements.
96
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
97
+        with contextlib.ExitStack() as stack:
98
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
99
+            stack.enter_context(
100
+                pytest_machinery.isolated_config(
101
+                    monkeypatch=monkeypatch,
102
+                    runner=runner,
103
+                )
104
+            )
105
+            cli_helpers.config_filename(
106
+                subsystem="old settings.json"
107
+            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
108
+            assert cli_helpers.migrate_and_load_old_config() == (config, None)
109
+
110
+    @Parametrize.BASE_CONFIG_VARIATIONS
111
+    def test_112_migrate_config_error(
112
+        self,
113
+        config: Any,
114
+    ) -> None:
115
+        """Migrating the old settings file atop a directory fails."""
116
+        runner = machinery.CliRunner(mix_stderr=False)
117
+        # TODO(the-13th-letter): Rewrite using parenthesized
118
+        # with-statements.
119
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
120
+        with contextlib.ExitStack() as stack:
121
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
122
+            stack.enter_context(
123
+                pytest_machinery.isolated_config(
124
+                    monkeypatch=monkeypatch,
125
+                    runner=runner,
126
+                )
127
+            )
128
+            cli_helpers.config_filename(
129
+                subsystem="old settings.json"
130
+            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
131
+            cli_helpers.config_filename(subsystem="vault").mkdir(
132
+                parents=True, exist_ok=True
133
+            )
134
+            config2, err = cli_helpers.migrate_and_load_old_config()
135
+            assert config2 == config
136
+            assert isinstance(err, OSError)
137
+            # The Annoying OS uses EEXIST, other OSes use EISDIR.
138
+            assert err.errno in {errno.EISDIR, errno.EEXIST}
139
+
140
+    @Parametrize.BAD_CONFIGS
141
+    def test_113_migrate_config_error_bad_config_value(
142
+        self,
143
+        config: Any,
144
+    ) -> None:
145
+        """Migrating an invalid old settings file fails."""
146
+        runner = machinery.CliRunner(mix_stderr=False)
147
+        # TODO(the-13th-letter): Rewrite using parenthesized
148
+        # with-statements.
149
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
150
+        with contextlib.ExitStack() as stack:
151
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
152
+            stack.enter_context(
153
+                pytest_machinery.isolated_config(
154
+                    monkeypatch=monkeypatch,
155
+                    runner=runner,
156
+                )
157
+            )
158
+            cli_helpers.config_filename(
159
+                subsystem="old settings.json"
160
+            ).write_text(json.dumps(config, indent=2) + "\n", encoding="UTF-8")
161
+            with pytest.raises(
162
+                ValueError, match=cli_helpers.INVALID_VAULT_CONFIG
163
+            ):
164
+                cli_helpers.migrate_and_load_old_config()
165
+
166
+    def test_200_forward_export_vault_path_parameter(
167
+        self,
168
+        caplog: pytest.LogCaptureFixture,
169
+    ) -> None:
170
+        """Forwarding arguments from "export" to "export vault" works."""
171
+        pytest.importorskip("cryptography", minversion="38.0")
172
+        runner = machinery.CliRunner(mix_stderr=False)
173
+        # TODO(the-13th-letter): Rewrite using parenthesized
174
+        # with-statements.
175
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
176
+        with contextlib.ExitStack() as stack:
177
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
178
+            stack.enter_context(
179
+                pytest_machinery.isolated_vault_exporter_config(
180
+                    monkeypatch=monkeypatch,
181
+                    runner=runner,
182
+                    vault_config=data.VAULT_V03_CONFIG,
183
+                    vault_key=data.VAULT_MASTER_KEY,
184
+                )
185
+            )
186
+            monkeypatch.setenv("VAULT_KEY", data.VAULT_MASTER_KEY)
187
+            result = runner.invoke(
188
+                cli.derivepassphrase,
189
+                ["export", "VAULT_PATH"],
190
+            )
191
+        assert result.clean_exit(empty_stderr=False), "expected clean exit"
192
+        assert machinery.deprecation_warning_emitted(
193
+            "A subcommand will be required here in v1.0", caplog.record_tuples
194
+        )
195
+        assert machinery.deprecation_warning_emitted(
196
+            'Defaulting to subcommand "vault"', caplog.record_tuples
197
+        )
198
+        assert json.loads(result.stdout) == data.VAULT_V03_CONFIG_DATA
199
+
200
+    def test_201_forward_export_vault_empty_commandline(
201
+        self,
202
+        caplog: pytest.LogCaptureFixture,
203
+    ) -> None:
204
+        """Deferring from "export" to "export vault" works."""
205
+        pytest.importorskip("cryptography", minversion="38.0")
206
+        runner = machinery.CliRunner(mix_stderr=False)
207
+        # TODO(the-13th-letter): Rewrite using parenthesized
208
+        # with-statements.
209
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
210
+        with contextlib.ExitStack() as stack:
211
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
212
+            stack.enter_context(
213
+                pytest_machinery.isolated_config(
214
+                    monkeypatch=monkeypatch,
215
+                    runner=runner,
216
+                )
217
+            )
218
+            result = runner.invoke(
219
+                cli.derivepassphrase,
220
+                ["export"],
221
+            )
222
+        assert machinery.deprecation_warning_emitted(
223
+            "A subcommand will be required here in v1.0", caplog.record_tuples
224
+        )
225
+        assert machinery.deprecation_warning_emitted(
226
+            'Defaulting to subcommand "vault"', caplog.record_tuples
227
+        )
228
+        assert result.error_exit(error="Missing argument 'PATH'"), (
229
+            "expected error exit and known error type"
230
+        )
231
+
232
+    @Parametrize.CHARSET_NAME
233
+    def test_210_forward_vault_disable_character_set(
234
+        self,
235
+        caplog: pytest.LogCaptureFixture,
236
+        charset_name: str,
237
+    ) -> None:
238
+        """Forwarding arguments from top-level to "vault" works."""
239
+        option = f"--{charset_name}"
240
+        charset = vault.Vault.CHARSETS[charset_name].decode("ascii")
241
+        runner = machinery.CliRunner(mix_stderr=False)
242
+        # TODO(the-13th-letter): Rewrite using parenthesized
243
+        # with-statements.
244
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
245
+        with contextlib.ExitStack() as stack:
246
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
247
+            stack.enter_context(
248
+                pytest_machinery.isolated_config(
249
+                    monkeypatch=monkeypatch,
250
+                    runner=runner,
251
+                )
252
+            )
253
+            monkeypatch.setattr(
254
+                cli_helpers,
255
+                "prompt_for_passphrase",
256
+                callables.auto_prompt,
257
+            )
258
+            result = runner.invoke(
259
+                cli.derivepassphrase,
260
+                [option, "0", "-p", "--", DUMMY_SERVICE],
261
+                input=DUMMY_PASSPHRASE,
262
+                catch_exceptions=False,
263
+            )
264
+        assert result.clean_exit(empty_stderr=False), "expected clean exit"
265
+        assert machinery.deprecation_warning_emitted(
266
+            "A subcommand will be required here in v1.0", caplog.record_tuples
267
+        )
268
+        assert machinery.deprecation_warning_emitted(
269
+            'Defaulting to subcommand "vault"', caplog.record_tuples
270
+        )
271
+        for c in charset:
272
+            assert c not in result.stdout, (
273
+                f"derived password contains forbidden character {c!r}"
274
+            )
275
+
276
+    def test_211_forward_vault_empty_command_line(
277
+        self,
278
+        caplog: pytest.LogCaptureFixture,
279
+    ) -> None:
280
+        """Deferring from top-level to "vault" works."""
281
+        runner = machinery.CliRunner(mix_stderr=False)
282
+        # TODO(the-13th-letter): Rewrite using parenthesized
283
+        # with-statements.
284
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
285
+        with contextlib.ExitStack() as stack:
286
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
287
+            stack.enter_context(
288
+                pytest_machinery.isolated_config(
289
+                    monkeypatch=monkeypatch,
290
+                    runner=runner,
291
+                )
292
+            )
293
+            result = runner.invoke(
294
+                cli.derivepassphrase,
295
+                [],
296
+                input=DUMMY_PASSPHRASE,
297
+                catch_exceptions=False,
298
+            )
299
+        assert machinery.deprecation_warning_emitted(
300
+            "A subcommand will be required here in v1.0", caplog.record_tuples
301
+        )
302
+        assert machinery.deprecation_warning_emitted(
303
+            'Defaulting to subcommand "vault"', caplog.record_tuples
304
+        )
305
+        assert result.error_exit(
306
+            error="Deriving a passphrase requires a SERVICE."
307
+        ), "expected error exit and known error type"
308
+
309
+    def test_300_export_using_old_config_file(
310
+        self,
311
+        caplog: pytest.LogCaptureFixture,
312
+    ) -> None:
313
+        """Exporting from (and migrating) the old settings file works."""
314
+        caplog.set_level(logging.INFO)
315
+        runner = machinery.CliRunner(mix_stderr=False)
316
+        # TODO(the-13th-letter): Rewrite using parenthesized
317
+        # with-statements.
318
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
319
+        with contextlib.ExitStack() as stack:
320
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
321
+            stack.enter_context(
322
+                pytest_machinery.isolated_config(
323
+                    monkeypatch=monkeypatch,
324
+                    runner=runner,
325
+                )
326
+            )
327
+            cli_helpers.config_filename(
328
+                subsystem="old settings.json"
329
+            ).write_text(
330
+                json.dumps(
331
+                    {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
332
+                    indent=2,
333
+                )
334
+                + "\n",
335
+                encoding="UTF-8",
336
+            )
337
+            result = runner.invoke(
338
+                cli.derivepassphrase_vault,
339
+                ["--export", "-"],
340
+                catch_exceptions=False,
341
+            )
342
+        assert result.clean_exit(), "expected clean exit"
343
+        assert machinery.deprecation_warning_emitted(
344
+            "v0.1-style config file", caplog.record_tuples
345
+        ), "expected known warning message in stderr"
346
+        assert machinery.deprecation_info_emitted(
347
+            "Successfully migrated to ", caplog.record_tuples
348
+        ), "expected known warning message in stderr"
349
+
350
+    def test_300a_export_using_old_config_file_migration_error(
351
+        self,
352
+        caplog: pytest.LogCaptureFixture,
353
+    ) -> None:
354
+        """Exporting from (and not migrating) the old settings file fails."""
355
+        runner = machinery.CliRunner(mix_stderr=False)
356
+        # TODO(the-13th-letter): Rewrite using parenthesized
357
+        # with-statements.
358
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
359
+        with contextlib.ExitStack() as stack:
360
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
361
+            stack.enter_context(
362
+                pytest_machinery.isolated_config(
363
+                    monkeypatch=monkeypatch,
364
+                    runner=runner,
365
+                )
366
+            )
367
+            cli_helpers.config_filename(
368
+                subsystem="old settings.json"
369
+            ).write_text(
370
+                json.dumps(
371
+                    {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
372
+                    indent=2,
373
+                )
374
+                + "\n",
375
+                encoding="UTF-8",
376
+            )
377
+
378
+            def raiser(*_args: Any, **_kwargs: Any) -> None:
379
+                raise OSError(
380
+                    errno.EACCES,
381
+                    os.strerror(errno.EACCES),
382
+                    cli_helpers.config_filename(subsystem="vault"),
383
+                )
384
+
385
+            monkeypatch.setattr(os, "replace", raiser)
386
+            monkeypatch.setattr(pathlib.Path, "rename", raiser)
387
+            result = runner.invoke(
388
+                cli.derivepassphrase_vault,
389
+                ["--export", "-"],
390
+                catch_exceptions=False,
391
+            )
392
+        assert result.clean_exit(), "expected clean exit"
393
+        assert machinery.deprecation_warning_emitted(
394
+            "v0.1-style config file", caplog.record_tuples
395
+        ), "expected known warning message in stderr"
396
+        assert machinery.warning_emitted(
397
+            "Failed to migrate to ", caplog.record_tuples
398
+        ), "expected known warning message in stderr"
399
+
400
+    def test_400_completion_service_name_old_config_file(
401
+        self,
402
+    ) -> None:
403
+        """Completing service names from the old settings file works."""
404
+        config = {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}
405
+        runner = machinery.CliRunner(mix_stderr=False)
406
+        # TODO(the-13th-letter): Rewrite using parenthesized
407
+        # with-statements.
408
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
409
+        with contextlib.ExitStack() as stack:
410
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
411
+            stack.enter_context(
412
+                pytest_machinery.isolated_vault_config(
413
+                    monkeypatch=monkeypatch,
414
+                    runner=runner,
415
+                    vault_config=config,
416
+                )
417
+            )
418
+            old_name = cli_helpers.config_filename(
419
+                subsystem="old settings.json"
420
+            )
421
+            new_name = cli_helpers.config_filename(subsystem="vault")
422
+            old_name.unlink(missing_ok=True)
423
+            new_name.rename(old_name)
424
+            assert cli_helpers.shell_complete_service(
425
+                click.Context(cli.derivepassphrase),
426
+                click.Argument(["some_parameter"]),
427
+                "",
428
+            ) == [DUMMY_SERVICE]
... ...
@@ -0,0 +1,1433 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+from __future__ import annotations
6
+
7
+import base64
8
+import contextlib
9
+import ctypes
10
+import enum
11
+import errno
12
+import io
13
+import json
14
+import logging
15
+import operator
16
+import os
17
+import pathlib
18
+import shlex
19
+import shutil
20
+import socket
21
+import tempfile
22
+import types
23
+import warnings
24
+from typing import TYPE_CHECKING
25
+
26
+import click.testing
27
+import hypothesis
28
+import pytest
29
+from hypothesis import strategies
30
+from typing_extensions import Any
31
+
32
+from derivepassphrase import _types, cli, ssh_agent, vault
33
+from derivepassphrase._internals import (
34
+    cli_helpers,
35
+    cli_machinery,
36
+)
37
+from derivepassphrase.ssh_agent import socketprovider
38
+from tests import data, machinery
39
+from tests.data import callables
40
+from tests.machinery import hypothesis as hypothesis_machinery
41
+from tests.machinery import pytest as pytest_machinery
42
+
43
+if TYPE_CHECKING:
44
+    from collections.abc import Callable, Iterable, Iterator
45
+    from typing import NoReturn
46
+
47
+
48
+DUMMY_SERVICE = data.DUMMY_SERVICE
49
+DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE
50
+DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS
51
+DUMMY_RESULT_PASSPHRASE = data.DUMMY_RESULT_PASSPHRASE
52
+DUMMY_RESULT_KEY1 = data.DUMMY_RESULT_KEY1
53
+DUMMY_PHRASE_FROM_KEY1_RAW = data.DUMMY_PHRASE_FROM_KEY1_RAW
54
+DUMMY_PHRASE_FROM_KEY1 = data.DUMMY_PHRASE_FROM_KEY1
55
+
56
+DUMMY_KEY1 = data.DUMMY_KEY1
57
+DUMMY_KEY1_B64 = data.DUMMY_KEY1_B64
58
+DUMMY_KEY2 = data.DUMMY_KEY2
59
+DUMMY_KEY2_B64 = data.DUMMY_KEY2_B64
60
+DUMMY_KEY3 = data.DUMMY_KEY3
61
+DUMMY_KEY3_B64 = data.DUMMY_KEY3_B64
62
+
63
+TEST_CONFIGS = data.TEST_CONFIGS
64
+
65
+
66
+def vault_config_exporter_shell_interpreter(  # noqa: C901
67
+    script: str | Iterable[str],
68
+    /,
69
+    *,
70
+    prog_name_list: list[str] | None = None,
71
+    command: click.BaseCommand | None = None,
72
+    runner: machinery.CliRunner | None = None,
73
+) -> Iterator[machinery.ReadableResult]:
74
+    """A rudimentary sh(1) interpreter for `--export-as=sh` output.
75
+
76
+    Assumes a script as emitted by `derivepassphrase vault
77
+    --export-as=sh --export -` and interprets the calls to
78
+    `derivepassphrase vault` within.  (One call per line, skips all
79
+    other lines.)  Also has rudimentary support for (quoted)
80
+    here-documents using `HERE` as the marker.
81
+
82
+    """
83
+    if isinstance(script, str):  # pragma: no cover
84
+        script = script.splitlines(False)
85
+    if prog_name_list is None:  # pragma: no cover
86
+        prog_name_list = ["derivepassphrase", "vault"]
87
+    if command is None:  # pragma: no cover
88
+        command = cli.derivepassphrase_vault
89
+    if runner is None:  # pragma: no cover
90
+        runner = machinery.CliRunner(mix_stderr=False)
91
+    n = len(prog_name_list)
92
+    it = iter(script)
93
+    while True:
94
+        try:
95
+            raw_line = next(it)
96
+        except StopIteration:
97
+            break
98
+        else:
99
+            line = shlex.split(raw_line)
100
+        input_buffer: list[str] = []
101
+        if line[:n] != prog_name_list:
102
+            continue
103
+        line[:n] = []
104
+        if line and line[-1] == "<<HERE":
105
+            # naive HERE document support
106
+            while True:
107
+                try:
108
+                    raw_line = next(it)
109
+                except StopIteration as exc:  # pragma: no cover
110
+                    msg = "incomplete here document"
111
+                    raise EOFError(msg) from exc
112
+                else:
113
+                    if raw_line == "HERE":
114
+                        break
115
+                    input_buffer.append(raw_line)
116
+            line.pop()
117
+        yield runner.invoke(
118
+            command,
119
+            line,
120
+            catch_exceptions=False,
121
+            input=("".join(x + "\n" for x in input_buffer) or None),
122
+        )
123
+
124
+
125
+class ListKeysAction(str, enum.Enum):
126
+    """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][].
127
+
128
+    Attributes:
129
+        EMPTY: Return an empty key list.
130
+        FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
131
+        FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
132
+
133
+    """
134
+
135
+    EMPTY = enum.auto()
136
+    """"""
137
+    FAIL = enum.auto()
138
+    """"""
139
+    FAIL_RUNTIME = enum.auto()
140
+    """"""
141
+
142
+    def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
143
+        """Execute the respective action."""
144
+        # TODO(the-13th-letter): Rewrite using structural pattern
145
+        # matching.
146
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
147
+        if self == self.EMPTY:
148
+            return []
149
+        if self == self.FAIL:
150
+            raise ssh_agent.SSHAgentFailedError(
151
+                _types.SSH_AGENT.FAILURE.value, b""
152
+            )
153
+        if self == self.FAIL_RUNTIME:
154
+            raise ssh_agent.TrailingDataError()
155
+        raise AssertionError()
156
+
157
+
158
+class SignAction(str, enum.Enum):
159
+    """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][].
160
+
161
+    Attributes:
162
+        FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
163
+        FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
164
+
165
+    """
166
+
167
+    FAIL = enum.auto()
168
+    """"""
169
+    FAIL_RUNTIME = enum.auto()
170
+    """"""
171
+
172
+    def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
173
+        """Execute the respective action."""
174
+        # TODO(the-13th-letter): Rewrite using structural pattern
175
+        # matching.
176
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
177
+        if self == self.FAIL:
178
+            raise ssh_agent.SSHAgentFailedError(
179
+                _types.SSH_AGENT.FAILURE.value, b""
180
+            )
181
+        if self == self.FAIL_RUNTIME:
182
+            raise ssh_agent.TrailingDataError()
183
+        raise AssertionError()
184
+
185
+
186
+class SocketAddressAction(str, enum.Enum):
187
+    """Test fixture settings for the SSH agent socket address.
188
+
189
+    Attributes:
190
+        MANGLE_ANNOYING_OS_NAMED_PIPE:
191
+            Mangle the address for the Annoying OS named pipe endpoint.
192
+        MANGLE_SSH_AUTH_SOCK:
193
+            Mangle the address for the UNIX domain socket (the
194
+            `SSH_AUTH_SOCK` environment variable).
195
+        UNSET_ANNOYING_OS_NAMED_PIPE:
196
+            Unset the address for the Annoying OS named pipe endpoint.
197
+        UNSET_SSH_AUTH_SOCK:
198
+            Unset the `SSH_AUTH_SOCK` environment variable (the address
199
+            for the UNIX domain socket).
200
+
201
+    """
202
+
203
+    MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto()
204
+    """"""
205
+    MANGLE_SSH_AUTH_SOCK = enum.auto()
206
+    """"""
207
+    UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto()
208
+    """"""
209
+    UNSET_SSH_AUTH_SOCK = enum.auto()
210
+    """"""
211
+
212
+    def __call__(
213
+        self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
214
+    ) -> None:
215
+        """Execute the respective action."""
216
+        # TODO(the-13th-letter): Rewrite using structural pattern
217
+        # matching.
218
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
219
+        if self in {
220
+            self.MANGLE_ANNOYING_OS_NAMED_PIPE,
221
+            self.UNSET_ANNOYING_OS_NAMED_PIPE,
222
+        }:  # pragma: no cover [unused]
223
+            pass
224
+        elif self == self.MANGLE_SSH_AUTH_SOCK:
225
+            monkeypatch.setenv(
226
+                "SSH_AUTH_SOCK", os.environ["SSH_AUTH_SOCK"] + "~"
227
+            )
228
+        elif self == self.UNSET_SSH_AUTH_SOCK:
229
+            monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
230
+        else:
231
+            raise AssertionError()
232
+
233
+
234
+class SystemSupportAction(str, enum.Enum):
235
+    """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support.
236
+
237
+    Attributes:
238
+        UNSET_AF_UNIX:
239
+            Ensure lack of support for UNIX domain sockets.
240
+        UNSET_AF_UNIX_AND_ENSURE_USE:
241
+            Ensure lack of support for UNIX domain sockets, and that the
242
+            agent will use this socket provider.
243
+        UNSET_NATIVE:
244
+            Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`.
245
+        UNSET_NATIVE_AND_ENSURE_USE:
246
+            Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the
247
+            agent will use the native socket provider.
248
+        UNSET_PROVIDER_LIST:
249
+            Ensure an empty list of SSH agent socket providers.
250
+        UNSET_WINDLL:
251
+            Ensure lack of support for The Annoying OS named pipes.
252
+        UNSET_WINDLL_AND_ENSURE_USE:
253
+            Ensure lack of support for The Annoying OS named pipes, and
254
+            that the agent will use this socket provider.
255
+
256
+    """
257
+
258
+    UNSET_AF_UNIX = enum.auto()
259
+    """"""
260
+    UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto()
261
+    """"""
262
+    UNSET_NATIVE = enum.auto()
263
+    """"""
264
+    UNSET_NATIVE_AND_ENSURE_USE = enum.auto()
265
+    """"""
266
+    UNSET_PROVIDER_LIST = enum.auto()
267
+    """"""
268
+    UNSET_WINDLL = enum.auto()
269
+    """"""
270
+    UNSET_WINDLL_AND_ENSURE_USE = enum.auto()
271
+    """"""
272
+
273
+    def __call__(
274
+        self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
275
+    ) -> None:
276
+        """Execute the respective action.
277
+
278
+        Args:
279
+            monkeypatch: The current monkeypatch context.
280
+
281
+        """
282
+        # TODO(the-13th-letter): Rewrite using structural pattern
283
+        # matching.
284
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
285
+        if self == self.UNSET_PROVIDER_LIST:
286
+            monkeypatch.setattr(
287
+                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", []
288
+            )
289
+        elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}:
290
+            self.check_or_ensure_use(
291
+                "native",
292
+                monkeypatch=monkeypatch,
293
+                ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE),
294
+            )
295
+            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
296
+            monkeypatch.delattr(ctypes, "WinDLL", raising=False)
297
+            monkeypatch.delattr(ctypes, "windll", raising=False)
298
+        elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}:
299
+            self.check_or_ensure_use(
300
+                "posix",
301
+                monkeypatch=monkeypatch,
302
+                ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE),
303
+            )
304
+            monkeypatch.delattr(socket, "AF_UNIX", raising=False)
305
+        elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}:
306
+            self.check_or_ensure_use(
307
+                "the_annoying_os",
308
+                monkeypatch=monkeypatch,
309
+                ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE),
310
+            )
311
+            monkeypatch.delattr(ctypes, "WinDLL", raising=False)
312
+            monkeypatch.delattr(ctypes, "windll", raising=False)
313
+        else:
314
+            raise AssertionError()
315
+
316
+    @staticmethod
317
+    def check_or_ensure_use(
318
+        provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool
319
+    ) -> None:
320
+        """Check that the named SSH agent socket provider will be used.
321
+
322
+        Either ensure that the socket provider will definitely be used,
323
+        or, upon detecting that it won't be used, skip the test.
324
+
325
+        Args:
326
+            provider:
327
+                The provider to check for.
328
+            ensure_use:
329
+                If true, ensure that the socket provider will definitely
330
+                be used.  If false, then check for whether it will be
331
+                used, and skip this test if not.
332
+            monkeypatch:
333
+                The monkeypatch context within which the fixture
334
+                adjustments should be executed.
335
+
336
+        """
337
+        if ensure_use:
338
+            monkeypatch.setattr(
339
+                ssh_agent.SSHAgentClient, "SOCKET_PROVIDERS", [provider]
340
+            )
341
+        else:  # pragma: no cover [external]
342
+            # This branch operates completely on instrumented or on
343
+            # externally defined, non-deterministic state.
344
+            intended: (
345
+                _types.SSHAgentSocketProvider
346
+                | socketprovider.NoSuchProviderError
347
+                | None
348
+            )
349
+            try:
350
+                intended = socketprovider.SocketProvider.lookup(provider)
351
+            except socketprovider.NoSuchProviderError as exc:
352
+                intended = exc
353
+            actual: (
354
+                _types.SSHAgentSocketProvider
355
+                | socketprovider.NoSuchProviderError
356
+                | None
357
+            )
358
+            for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS:
359
+                try:
360
+                    actual = socketprovider.SocketProvider.lookup(name)
361
+                except socketprovider.NoSuchProviderError as exc:
362
+                    actual = exc
363
+                if actual is None:
364
+                    continue
365
+                break
366
+            else:
367
+                actual = None
368
+            if intended != actual:
369
+                pytest.skip(
370
+                    f"{provider!r} SSH agent socket provider "
371
+                    f"is not currently in use"
372
+                )
373
+
374
+
375
+class Parametrize(types.SimpleNamespace):
376
+    """Common test parametrizations."""
377
+
378
+    DELETE_CONFIG_INPUT = pytest.mark.parametrize(
379
+        ["command_line", "config", "result_config"],
380
+        [
381
+            pytest.param(
382
+                ["--delete-globals"],
383
+                {"global": {"phrase": "abc"}, "services": {}},
384
+                {"services": {}},
385
+                id="globals",
386
+            ),
387
+            pytest.param(
388
+                ["--delete", "--", DUMMY_SERVICE],
389
+                {
390
+                    "global": {"phrase": "abc"},
391
+                    "services": {DUMMY_SERVICE: {"notes": "..."}},
392
+                },
393
+                {"global": {"phrase": "abc"}, "services": {}},
394
+                id="service",
395
+            ),
396
+            pytest.param(
397
+                ["--clear"],
398
+                {
399
+                    "global": {"phrase": "abc"},
400
+                    "services": {DUMMY_SERVICE: {"notes": "..."}},
401
+                },
402
+                {"services": {}},
403
+                id="all",
404
+            ),
405
+        ],
406
+    )
407
+    BASE_CONFIG_VARIATIONS = pytest.mark.parametrize(
408
+        "config",
409
+        [
410
+            {"global": {"phrase": "my passphrase"}, "services": {}},
411
+            {"global": {"key": DUMMY_KEY1_B64}, "services": {}},
412
+            {
413
+                "global": {"phrase": "abc"},
414
+                "services": {"sv": {"phrase": "my passphrase"}},
415
+            },
416
+            {
417
+                "global": {"phrase": "abc"},
418
+                "services": {"sv": {"key": DUMMY_KEY1_B64}},
419
+            },
420
+            {
421
+                "global": {"phrase": "abc"},
422
+                "services": {"sv": {"key": DUMMY_KEY1_B64, "length": 15}},
423
+            },
424
+        ],
425
+    )
426
+    CONNECTION_HINTS = pytest.mark.parametrize(
427
+        "conn_hint", ["none", "socket", "client"]
428
+    )
429
+    KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize(
430
+        [
431
+            "list_keys_action",
432
+            "address_action",
433
+            "system_support_action",
434
+            "sign_action",
435
+            "pattern",
436
+        ],
437
+        [
438
+            pytest.param(
439
+                ListKeysAction.EMPTY,
440
+                None,
441
+                None,
442
+                SignAction.FAIL,
443
+                "not loaded into the agent",
444
+                id="key-not-loaded",
445
+            ),
446
+            pytest.param(
447
+                ListKeysAction.FAIL,
448
+                None,
449
+                None,
450
+                SignAction.FAIL,
451
+                "SSH agent failed to or refused to",
452
+                id="list-keys-refused",
453
+            ),
454
+            pytest.param(
455
+                ListKeysAction.FAIL_RUNTIME,
456
+                None,
457
+                None,
458
+                SignAction.FAIL,
459
+                "SSH agent failed to or refused to",
460
+                id="list-keys-protocol-error",
461
+            ),
462
+            pytest.param(
463
+                None,
464
+                SocketAddressAction.UNSET_SSH_AUTH_SOCK,
465
+                None,
466
+                SignAction.FAIL,
467
+                "Cannot find any running SSH agent",
468
+                id="agent-address-missing",
469
+            ),
470
+            pytest.param(
471
+                None,
472
+                SocketAddressAction.MANGLE_SSH_AUTH_SOCK,
473
+                None,
474
+                SignAction.FAIL,
475
+                "Cannot connect to the SSH agent",
476
+                id="agent-address-mangled",
477
+            ),
478
+            pytest.param(
479
+                None,
480
+                None,
481
+                SystemSupportAction.UNSET_NATIVE,
482
+                SignAction.FAIL,
483
+                "does not support communicating with it",
484
+                id="no-agent-support",
485
+            ),
486
+            pytest.param(
487
+                None,
488
+                None,
489
+                SystemSupportAction.UNSET_PROVIDER_LIST,
490
+                SignAction.FAIL,
491
+                "does not support communicating with it",
492
+                id="no-agent-support",
493
+            ),
494
+            pytest.param(
495
+                None,
496
+                None,
497
+                SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE,
498
+                SignAction.FAIL,
499
+                "does not support communicating with it",
500
+                id="no-agent-support",
501
+            ),
502
+            pytest.param(
503
+                None,
504
+                None,
505
+                SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE,
506
+                SignAction.FAIL,
507
+                "does not support communicating with it",
508
+                id="no-agent-support",
509
+            ),
510
+            pytest.param(
511
+                None,
512
+                None,
513
+                None,
514
+                SignAction.FAIL_RUNTIME,
515
+                "violates the communication protocol",
516
+                id="sign-violates-protocol",
517
+            ),
518
+        ],
519
+    )
520
+    VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize(
521
+        ["vfunc", "input"],
522
+        [
523
+            (cli_machinery.validate_occurrence_constraint, 20),
524
+            (cli_machinery.validate_length, 20),
525
+        ],
526
+    )
527
+
528
+
529
+class TestCLIUtils:
530
+    """Tests for command-line utility functions."""
531
+
532
+    @Parametrize.BASE_CONFIG_VARIATIONS
533
+    def test_100_load_config(
534
+        self,
535
+        config: Any,
536
+    ) -> None:
537
+        """[`cli_helpers.load_config`][] works for valid configurations."""
538
+        runner = machinery.CliRunner(mix_stderr=False)
539
+        # TODO(the-13th-letter): Rewrite using parenthesized
540
+        # with-statements.
541
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
542
+        with contextlib.ExitStack() as stack:
543
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
544
+            stack.enter_context(
545
+                pytest_machinery.isolated_vault_config(
546
+                    monkeypatch=monkeypatch,
547
+                    runner=runner,
548
+                    vault_config=config,
549
+                )
550
+            )
551
+            config_filename = cli_helpers.config_filename(subsystem="vault")
552
+            with config_filename.open(encoding="UTF-8") as fileobj:
553
+                assert json.load(fileobj) == config
554
+            assert cli_helpers.load_config() == config
555
+
556
+    def test_110_save_bad_config(
557
+        self,
558
+    ) -> None:
559
+        """[`cli_helpers.save_config`][] fails for bad configurations."""
560
+        runner = machinery.CliRunner(mix_stderr=False)
561
+        # TODO(the-13th-letter): Rewrite using parenthesized
562
+        # with-statements.
563
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
564
+        with contextlib.ExitStack() as stack:
565
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
566
+            stack.enter_context(
567
+                pytest_machinery.isolated_vault_config(
568
+                    monkeypatch=monkeypatch,
569
+                    runner=runner,
570
+                    vault_config={},
571
+                )
572
+            )
573
+            stack.enter_context(
574
+                pytest.raises(ValueError, match="Invalid vault config")
575
+            )
576
+            cli_helpers.save_config(None)  # type: ignore[arg-type]
577
+
578
+    def test_111_prompt_for_selection_multiple(self) -> None:
579
+        """[`cli_helpers.prompt_for_selection`][] works in the "multiple" case."""
580
+
581
+        @click.command()
582
+        @click.option("--heading", default="Our menu:")
583
+        @click.argument("items", nargs=-1)
584
+        def driver(heading: str, items: list[str]) -> None:
585
+            # from https://montypython.fandom.com/wiki/Spam#The_menu
586
+            items = items or [
587
+                "Egg and bacon",
588
+                "Egg, sausage and bacon",
589
+                "Egg and spam",
590
+                "Egg, bacon and spam",
591
+                "Egg, bacon, sausage and spam",
592
+                "Spam, bacon, sausage and spam",
593
+                "Spam, egg, spam, spam, bacon and spam",
594
+                "Spam, spam, spam, egg and spam",
595
+                (
596
+                    "Spam, spam, spam, spam, spam, spam, baked beans, "
597
+                    "spam, spam, spam and spam"
598
+                ),
599
+                (
600
+                    "Lobster thermidor aux crevettes with a mornay sauce "
601
+                    "garnished with truffle paté, brandy "
602
+                    "and a fried egg on top and spam"
603
+                ),
604
+            ]
605
+            index = cli_helpers.prompt_for_selection(items, heading=heading)
606
+            click.echo("A fine choice: ", nl=False)
607
+            click.echo(items[index])
608
+            click.echo("(Note: Vikings strictly optional.)")
609
+
610
+        runner = machinery.CliRunner(mix_stderr=True)
611
+        result = runner.invoke(driver, [], input="9")
612
+        assert result.clean_exit(
613
+            output="""\
614
+Our menu:
615
+[1] Egg and bacon
616
+[2] Egg, sausage and bacon
617
+[3] Egg and spam
618
+[4] Egg, bacon and spam
619
+[5] Egg, bacon, sausage and spam
620
+[6] Spam, bacon, sausage and spam
621
+[7] Spam, egg, spam, spam, bacon and spam
622
+[8] Spam, spam, spam, egg and spam
623
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
624
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
625
+Your selection? (1-10, leave empty to abort): 9
626
+A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
627
+(Note: Vikings strictly optional.)
628
+"""
629
+        ), "expected clean exit"
630
+        result = runner.invoke(
631
+            driver, ["--heading="], input="\n", catch_exceptions=True
632
+        )
633
+        assert result.error_exit(error=IndexError), (
634
+            "expected error exit and known error type"
635
+        )
636
+        assert (
637
+            result.stdout
638
+            == """\
639
+[1] Egg and bacon
640
+[2] Egg, sausage and bacon
641
+[3] Egg and spam
642
+[4] Egg, bacon and spam
643
+[5] Egg, bacon, sausage and spam
644
+[6] Spam, bacon, sausage and spam
645
+[7] Spam, egg, spam, spam, bacon and spam
646
+[8] Spam, spam, spam, egg and spam
647
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
648
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
649
+Your selection? (1-10, leave empty to abort):\x20
650
+"""
651
+        ), "expected known output"
652
+        # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
653
+        # click prompting machinery, meaning that the mixed output will
654
+        # incorrectly contain a line break, contrary to what the
655
+        # documentation for click.prompt prescribes.
656
+        result = runner.invoke(
657
+            driver, ["--heading="], input="", catch_exceptions=True
658
+        )
659
+        assert result.error_exit(error=IndexError), (
660
+            "expected error exit and known error type"
661
+        )
662
+        assert result.stdout in {
663
+            """\
664
+[1] Egg and bacon
665
+[2] Egg, sausage and bacon
666
+[3] Egg and spam
667
+[4] Egg, bacon and spam
668
+[5] Egg, bacon, sausage and spam
669
+[6] Spam, bacon, sausage and spam
670
+[7] Spam, egg, spam, spam, bacon and spam
671
+[8] Spam, spam, spam, egg and spam
672
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
673
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
674
+Your selection? (1-10, leave empty to abort):\x20
675
+""",
676
+            """\
677
+[1] Egg and bacon
678
+[2] Egg, sausage and bacon
679
+[3] Egg and spam
680
+[4] Egg, bacon and spam
681
+[5] Egg, bacon, sausage and spam
682
+[6] Spam, bacon, sausage and spam
683
+[7] Spam, egg, spam, spam, bacon and spam
684
+[8] Spam, spam, spam, egg and spam
685
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
686
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
687
+Your selection? (1-10, leave empty to abort): """,
688
+        }, "expected known output"
689
+
690
+    def test_112_prompt_for_selection_single(self) -> None:
691
+        """[`cli_helpers.prompt_for_selection`][] works in the "single" case."""
692
+
693
+        @click.command()
694
+        @click.option("--item", default="baked beans")
695
+        @click.argument("prompt")
696
+        def driver(item: str, prompt: str) -> None:
697
+            try:
698
+                cli_helpers.prompt_for_selection(
699
+                    [item], heading="", single_choice_prompt=prompt
700
+                )
701
+            except IndexError:
702
+                click.echo("Boo.")
703
+                raise
704
+            else:
705
+                click.echo("Great!")
706
+
707
+        runner = machinery.CliRunner(mix_stderr=True)
708
+        result = runner.invoke(
709
+            driver, ["Will replace with spam. Confirm, y/n?"], input="y"
710
+        )
711
+        assert result.clean_exit(
712
+            output="""\
713
+[1] baked beans
714
+Will replace with spam. Confirm, y/n? y
715
+Great!
716
+"""
717
+        ), "expected clean exit"
718
+        result = runner.invoke(
719
+            driver,
720
+            ['Will replace with spam, okay? (Please say "y" or "n".)'],
721
+            input="\n",
722
+        )
723
+        assert result.error_exit(error=IndexError), (
724
+            "expected error exit and known error type"
725
+        )
726
+        assert (
727
+            result.stdout
728
+            == """\
729
+[1] baked beans
730
+Will replace with spam, okay? (Please say "y" or "n".):\x20
731
+Boo.
732
+"""
733
+        ), "expected known output"
734
+        # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
735
+        # click prompting machinery, meaning that the mixed output will
736
+        # incorrectly contain a line break, contrary to what the
737
+        # documentation for click.prompt prescribes.
738
+        result = runner.invoke(
739
+            driver,
740
+            ['Will replace with spam, okay? (Please say "y" or "n".)'],
741
+            input="",
742
+        )
743
+        assert result.error_exit(error=IndexError), (
744
+            "expected error exit and known error type"
745
+        )
746
+        assert result.stdout in {
747
+            """\
748
+[1] baked beans
749
+Will replace with spam, okay? (Please say "y" or "n".):\x20
750
+Boo.
751
+""",
752
+            """\
753
+[1] baked beans
754
+Will replace with spam, okay? (Please say "y" or "n".): Boo.
755
+""",
756
+        }, "expected known output"
757
+
758
+    def test_113_prompt_for_passphrase(
759
+        self,
760
+    ) -> None:
761
+        """[`cli_helpers.prompt_for_passphrase`][] works."""
762
+        with pytest.MonkeyPatch.context() as monkeypatch:
763
+            monkeypatch.setattr(
764
+                click,
765
+                "prompt",
766
+                lambda *a, **kw: json.dumps({"args": a, "kwargs": kw}),
767
+            )
768
+            res = json.loads(cli_helpers.prompt_for_passphrase())
769
+        err_msg = "missing arguments to passphrase prompt"
770
+        assert "args" in res, err_msg
771
+        assert "kwargs" in res, err_msg
772
+        assert res["args"][:1] == ["Passphrase"], err_msg
773
+        assert res["kwargs"].get("default") == "", err_msg
774
+        assert not res["kwargs"].get("show_default", True), err_msg
775
+        assert res["kwargs"].get("err"), err_msg
776
+        assert res["kwargs"].get("hide_input"), err_msg
777
+
778
+    def test_120_standard_logging_context_manager(
779
+        self,
780
+        caplog: pytest.LogCaptureFixture,
781
+        capsys: pytest.CaptureFixture[str],
782
+    ) -> None:
783
+        """The standard logging context manager works.
784
+
785
+        It registers its handlers, once, and emits formatted calls to
786
+        standard error prefixed with the program name.
787
+
788
+        """
789
+        prog_name = cli_machinery.StandardCLILogging.prog_name
790
+        package_name = cli_machinery.StandardCLILogging.package_name
791
+        logger = logging.getLogger(package_name)
792
+        deprecation_logger = logging.getLogger(f"{package_name}.deprecation")
793
+        logging_cm = cli_machinery.StandardCLILogging.ensure_standard_logging()
794
+        with logging_cm:
795
+            assert (
796
+                sum(
797
+                    1
798
+                    for h in logger.handlers
799
+                    if h is cli_machinery.StandardCLILogging.cli_handler
800
+                )
801
+                == 1
802
+            )
803
+            logger.warning("message 1")
804
+            with logging_cm:
805
+                deprecation_logger.warning("message 2")
806
+                assert (
807
+                    sum(
808
+                        1
809
+                        for h in logger.handlers
810
+                        if h is cli_machinery.StandardCLILogging.cli_handler
811
+                    )
812
+                    == 1
813
+                )
814
+                assert capsys.readouterr() == (
815
+                    "",
816
+                    (
817
+                        f"{prog_name}: Warning: message 1\n"
818
+                        f"{prog_name}: Deprecation warning: message 2\n"
819
+                    ),
820
+                )
821
+            logger.warning("message 3")
822
+            assert (
823
+                sum(
824
+                    1
825
+                    for h in logger.handlers
826
+                    if h is cli_machinery.StandardCLILogging.cli_handler
827
+                )
828
+                == 1
829
+            )
830
+            assert capsys.readouterr() == (
831
+                "",
832
+                f"{prog_name}: Warning: message 3\n",
833
+            )
834
+            assert caplog.record_tuples == [
835
+                (package_name, logging.WARNING, "message 1"),
836
+                (f"{package_name}.deprecation", logging.WARNING, "message 2"),
837
+                (package_name, logging.WARNING, "message 3"),
838
+            ]
839
+
840
+    def test_121_standard_logging_warnings_context_manager(
841
+        self,
842
+        caplog: pytest.LogCaptureFixture,
843
+        capsys: pytest.CaptureFixture[str],
844
+    ) -> None:
845
+        """The standard warnings logging context manager works.
846
+
847
+        It registers its handlers, once, and emits formatted calls to
848
+        standard error prefixed with the program name.  It also adheres
849
+        to the global warnings filter concerning which messages it
850
+        actually emits to standard error.
851
+
852
+        """
853
+        warnings_cm = (
854
+            cli_machinery.StandardCLILogging.ensure_standard_warnings_logging()
855
+        )
856
+        THE_FUTURE = "the future will be here sooner than you think"  # noqa: N806
857
+        JUST_TESTING = "just testing whether warnings work"  # noqa: N806
858
+        with warnings_cm:
859
+            assert (
860
+                sum(
861
+                    1
862
+                    for h in logging.getLogger("py.warnings").handlers
863
+                    if h is cli_machinery.StandardCLILogging.warnings_handler
864
+                )
865
+                == 1
866
+            )
867
+            warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
868
+            with warnings_cm:
869
+                warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1)
870
+                _out, err = capsys.readouterr()
871
+                err_lines = err.splitlines(True)
872
+                assert any(
873
+                    f"UserWarning: {JUST_TESTING}" in line
874
+                    for line in err_lines
875
+                )
876
+                assert any(
877
+                    f"FutureWarning: {THE_FUTURE}" in line
878
+                    for line in err_lines
879
+                )
880
+            warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
881
+            _out, err = capsys.readouterr()
882
+            err_lines = err.splitlines(True)
883
+            assert any(
884
+                f"UserWarning: {JUST_TESTING}" in line for line in err_lines
885
+            )
886
+            assert not any(
887
+                f"FutureWarning: {THE_FUTURE}" in line for line in err_lines
888
+            )
889
+            record_tuples = caplog.record_tuples
890
+            assert [tup[:2] for tup in record_tuples] == [
891
+                ("py.warnings", logging.WARNING),
892
+                ("py.warnings", logging.WARNING),
893
+                ("py.warnings", logging.WARNING),
894
+            ]
895
+            assert f"UserWarning: {JUST_TESTING}" in record_tuples[0][2]
896
+            assert f"FutureWarning: {THE_FUTURE}" in record_tuples[1][2]
897
+            assert f"UserWarning: {JUST_TESTING}" in record_tuples[2][2]
898
+
899
+    def export_as_sh_helper(
900
+        self,
901
+        config: Any,
902
+    ) -> None:
903
+        """Emits a config in sh(1) format, then reads it back to verify it.
904
+
905
+        This function exports the configuration, sets up a new
906
+        enviroment, then calls
907
+        [`vault_config_exporter_shell_interpreter`][] on the export
908
+        script, verifying that each command ran successfully and that
909
+        the final configuration matches the initial one.
910
+
911
+        Args:
912
+            config:
913
+                The configuration to emit and read back.
914
+
915
+        """
916
+        prog_name_list = ("derivepassphrase", "vault")
917
+        with io.StringIO() as outfile:
918
+            cli_helpers.print_config_as_sh_script(
919
+                config, outfile=outfile, prog_name_list=prog_name_list
920
+            )
921
+            script = outfile.getvalue()
922
+        runner = machinery.CliRunner(mix_stderr=False)
923
+        # TODO(the-13th-letter): Rewrite using parenthesized
924
+        # with-statements.
925
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
926
+        with contextlib.ExitStack() as stack:
927
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
928
+            stack.enter_context(
929
+                pytest_machinery.isolated_vault_config(
930
+                    monkeypatch=monkeypatch,
931
+                    runner=runner,
932
+                    vault_config={"services": {}},
933
+                )
934
+            )
935
+            for result in vault_config_exporter_shell_interpreter(script):
936
+                assert result.clean_exit()
937
+            assert cli_helpers.load_config() == config
938
+
939
+    @hypothesis.given(
940
+        global_config_settable=hypothesis_machinery.vault_full_service_config(),
941
+        global_config_importable=strategies.fixed_dictionaries(
942
+            {},
943
+            optional={
944
+                "key": strategies.text(
945
+                    alphabet=strategies.characters(
946
+                        min_codepoint=32,
947
+                        max_codepoint=126,
948
+                    ),
949
+                    max_size=128,
950
+                ),
951
+                "phrase": strategies.text(
952
+                    alphabet=strategies.characters(
953
+                        min_codepoint=32,
954
+                        max_codepoint=126,
955
+                    ),
956
+                    max_size=64,
957
+                ),
958
+            },
959
+        ),
960
+    )
961
+    def test_130a_export_as_sh_global(
962
+        self,
963
+        global_config_settable: _types.VaultConfigServicesSettings,
964
+        global_config_importable: _types.VaultConfigServicesSettings,
965
+    ) -> None:
966
+        """Exporting configurations as sh(1) script works.
967
+
968
+        Here, we check global-only configurations which use both
969
+        settings settable via `--config` and settings requiring
970
+        `--import`.
971
+
972
+        The actual verification is done by [`export_as_sh_helper`][].
973
+
974
+        """
975
+        config: _types.VaultConfig = {
976
+            "global": global_config_settable | global_config_importable,
977
+            "services": {},
978
+        }
979
+        assert _types.clean_up_falsy_vault_config_values(config) is not None
980
+        assert _types.is_vault_config(config)
981
+        return self.export_as_sh_helper(config)
982
+
983
+    @hypothesis.given(
984
+        global_config_importable=strategies.fixed_dictionaries(
985
+            {},
986
+            optional={
987
+                "key": strategies.text(
988
+                    alphabet=strategies.characters(
989
+                        min_codepoint=32,
990
+                        max_codepoint=126,
991
+                    ),
992
+                    max_size=128,
993
+                ),
994
+                "phrase": strategies.text(
995
+                    alphabet=strategies.characters(
996
+                        min_codepoint=32,
997
+                        max_codepoint=126,
998
+                    ),
999
+                    max_size=64,
1000
+                ),
1001
+            },
1002
+        ),
1003
+    )
1004
+    def test_130b_export_as_sh_global_only_imports(
1005
+        self,
1006
+        global_config_importable: _types.VaultConfigServicesSettings,
1007
+    ) -> None:
1008
+        """Exporting configurations as sh(1) script works.
1009
+
1010
+        Here, we check global-only configurations which only use
1011
+        settings requiring `--import`.
1012
+
1013
+        The actual verification is done by [`export_as_sh_helper`][].
1014
+
1015
+        """
1016
+        config: _types.VaultConfig = {
1017
+            "global": global_config_importable,
1018
+            "services": {},
1019
+        }
1020
+        assert _types.clean_up_falsy_vault_config_values(config) is not None
1021
+        assert _types.is_vault_config(config)
1022
+        if not config["global"]:
1023
+            config.pop("global")
1024
+        return self.export_as_sh_helper(config)
1025
+
1026
+    @hypothesis.given(
1027
+        service_name=strategies.text(
1028
+            alphabet=strategies.characters(
1029
+                min_codepoint=32,
1030
+                max_codepoint=126,
1031
+            ),
1032
+            min_size=4,
1033
+            max_size=64,
1034
+        ),
1035
+        service_config_settable=hypothesis_machinery.vault_full_service_config(),
1036
+        service_config_importable=strategies.fixed_dictionaries(
1037
+            {},
1038
+            optional={
1039
+                "key": strategies.text(
1040
+                    alphabet=strategies.characters(
1041
+                        min_codepoint=32,
1042
+                        max_codepoint=126,
1043
+                    ),
1044
+                    max_size=128,
1045
+                ),
1046
+                "phrase": strategies.text(
1047
+                    alphabet=strategies.characters(
1048
+                        min_codepoint=32,
1049
+                        max_codepoint=126,
1050
+                    ),
1051
+                    max_size=64,
1052
+                ),
1053
+                "notes": strategies.text(
1054
+                    alphabet=strategies.characters(
1055
+                        min_codepoint=32,
1056
+                        max_codepoint=126,
1057
+                        include_characters=("\n", "\f", "\t"),
1058
+                    ),
1059
+                    max_size=256,
1060
+                ),
1061
+            },
1062
+        ),
1063
+    )
1064
+    def test_130c_export_as_sh_service(
1065
+        self,
1066
+        service_name: str,
1067
+        service_config_settable: _types.VaultConfigServicesSettings,
1068
+        service_config_importable: _types.VaultConfigServicesSettings,
1069
+    ) -> None:
1070
+        """Exporting configurations as sh(1) script works.
1071
+
1072
+        Here, we check service-only configurations which use both
1073
+        settings settable via `--config` and settings requiring
1074
+        `--import`.
1075
+
1076
+        The actual verification is done by [`export_as_sh_helper`][].
1077
+
1078
+        """
1079
+        config: _types.VaultConfig = {
1080
+            "services": {
1081
+                service_name: (
1082
+                    service_config_settable | service_config_importable
1083
+                ),
1084
+            },
1085
+        }
1086
+        assert _types.clean_up_falsy_vault_config_values(config) is not None
1087
+        assert _types.is_vault_config(config)
1088
+        return self.export_as_sh_helper(config)
1089
+
1090
+    @hypothesis.given(
1091
+        service_name=strategies.text(
1092
+            alphabet=strategies.characters(
1093
+                min_codepoint=32,
1094
+                max_codepoint=126,
1095
+            ),
1096
+            min_size=4,
1097
+            max_size=64,
1098
+        ),
1099
+        service_config_importable=strategies.fixed_dictionaries(
1100
+            {},
1101
+            optional={
1102
+                "key": strategies.text(
1103
+                    alphabet=strategies.characters(
1104
+                        min_codepoint=32,
1105
+                        max_codepoint=126,
1106
+                    ),
1107
+                    max_size=128,
1108
+                ),
1109
+                "phrase": strategies.text(
1110
+                    alphabet=strategies.characters(
1111
+                        min_codepoint=32,
1112
+                        max_codepoint=126,
1113
+                    ),
1114
+                    max_size=64,
1115
+                ),
1116
+                "notes": strategies.text(
1117
+                    alphabet=strategies.characters(
1118
+                        min_codepoint=32,
1119
+                        max_codepoint=126,
1120
+                        include_characters=("\n", "\f", "\t"),
1121
+                    ),
1122
+                    max_size=256,
1123
+                ),
1124
+            },
1125
+        ),
1126
+    )
1127
+    def test_130d_export_as_sh_service_only_imports(
1128
+        self,
1129
+        service_name: str,
1130
+        service_config_importable: _types.VaultConfigServicesSettings,
1131
+    ) -> None:
1132
+        """Exporting configurations as sh(1) script works.
1133
+
1134
+        Here, we check service-only configurations which only use
1135
+        settings requiring `--import`.
1136
+
1137
+        The actual verification is done by [`export_as_sh_helper`][].
1138
+
1139
+        """
1140
+        config: _types.VaultConfig = {
1141
+            "services": {
1142
+                service_name: service_config_importable,
1143
+            },
1144
+        }
1145
+        assert _types.clean_up_falsy_vault_config_values(config) is not None
1146
+        assert _types.is_vault_config(config)
1147
+        return self.export_as_sh_helper(config)
1148
+
1149
+    # The Annoying OS appears to silently truncate spaces at the end of
1150
+    # filenames.
1151
+    @hypothesis.given(
1152
+        env_var=strategies.sampled_from(["TMPDIR", "TEMP", "TMP"]),
1153
+        suffix=strategies.builds(
1154
+            operator.add,
1155
+            strategies.text(
1156
+                tuple(" 0123456789abcdefghijklmnopqrstuvwxyz"),
1157
+                min_size=11,
1158
+                max_size=11,
1159
+            ),
1160
+            strategies.text(
1161
+                tuple("0123456789abcdefghijklmnopqrstuvwxyz"),
1162
+                min_size=1,
1163
+                max_size=1,
1164
+            ),
1165
+        ),
1166
+    )
1167
+    @hypothesis.example(env_var="", suffix=".")
1168
+    def test_140a_get_tempdir(
1169
+        self,
1170
+        env_var: str,
1171
+        suffix: str,
1172
+    ) -> None:
1173
+        """[`cli_helpers.get_tempdir`][] returns a temporary directory.
1174
+
1175
+        If it is not the same as the temporary directory determined by
1176
+        [`tempfile.gettempdir`][], then assert that
1177
+        `tempfile.gettempdir` returned the current directory and
1178
+        `cli_helpers.get_tempdir` returned the configuration directory.
1179
+
1180
+        """
1181
+
1182
+        @contextlib.contextmanager
1183
+        def make_temporary_directory(
1184
+            path: pathlib.Path,
1185
+        ) -> Iterator[pathlib.Path]:
1186
+            try:
1187
+                path.mkdir()
1188
+                yield path
1189
+            finally:
1190
+                shutil.rmtree(path)
1191
+
1192
+        runner = machinery.CliRunner(mix_stderr=False)
1193
+        # TODO(the-13th-letter): Rewrite using parenthesized
1194
+        # with-statements.
1195
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1196
+        with contextlib.ExitStack() as stack:
1197
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1198
+            stack.enter_context(
1199
+                pytest_machinery.isolated_vault_config(
1200
+                    monkeypatch=monkeypatch,
1201
+                    runner=runner,
1202
+                    vault_config={"services": {}},
1203
+                )
1204
+            )
1205
+            old_tempdir = os.fsdecode(tempfile.gettempdir())
1206
+            monkeypatch.delenv("TMPDIR", raising=False)
1207
+            monkeypatch.delenv("TEMP", raising=False)
1208
+            monkeypatch.delenv("TMP", raising=False)
1209
+            monkeypatch.setattr(tempfile, "tempdir", None)
1210
+            temp_path = pathlib.Path.cwd() / suffix
1211
+            if env_var:
1212
+                monkeypatch.setenv(env_var, os.fsdecode(temp_path))
1213
+                stack.enter_context(make_temporary_directory(temp_path))
1214
+            new_tempdir = os.fsdecode(tempfile.gettempdir())
1215
+            hypothesis.assume(
1216
+                temp_path.resolve() == pathlib.Path.cwd().resolve()
1217
+                or old_tempdir != new_tempdir
1218
+            )
1219
+            system_tempdir = os.fsdecode(tempfile.gettempdir())
1220
+            our_tempdir = cli_helpers.get_tempdir()
1221
+            assert system_tempdir == os.fsdecode(our_tempdir) or (
1222
+                # TODO(the-13th-letter): `pytest_machinery.isolated_config`
1223
+                # guarantees that `Path.cwd() == config_filename(None)`.
1224
+                # So this sub-branch ought to never trigger in our
1225
+                # tests.
1226
+                system_tempdir == os.getcwd()  # noqa: PTH109
1227
+                and our_tempdir == cli_helpers.config_filename(subsystem=None)
1228
+            )
1229
+        assert not temp_path.exists(), f"temp path {temp_path} not cleaned up!"
1230
+
1231
+    def test_140b_get_tempdir_force_default(self) -> None:
1232
+        """[`cli_helpers.get_tempdir`][] returns a temporary directory.
1233
+
1234
+        If all candidates are mocked to fail for the standard temporary
1235
+        directory choices, then we return the `derivepassphrase`
1236
+        configuration directory.
1237
+
1238
+        """
1239
+        runner = machinery.CliRunner(mix_stderr=False)
1240
+        # TODO(the-13th-letter): Rewrite using parenthesized
1241
+        # with-statements.
1242
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1243
+        with contextlib.ExitStack() as stack:
1244
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1245
+            stack.enter_context(
1246
+                pytest_machinery.isolated_vault_config(
1247
+                    monkeypatch=monkeypatch,
1248
+                    runner=runner,
1249
+                    vault_config={"services": {}},
1250
+                )
1251
+            )
1252
+            monkeypatch.delenv("TMPDIR", raising=False)
1253
+            monkeypatch.delenv("TEMP", raising=False)
1254
+            monkeypatch.delenv("TMP", raising=False)
1255
+            config_dir = cli_helpers.config_filename(subsystem=None)
1256
+
1257
+            def is_dir_false(
1258
+                self: pathlib.Path,
1259
+                /,
1260
+                *,
1261
+                follow_symlinks: bool = False,
1262
+            ) -> bool:
1263
+                del self, follow_symlinks
1264
+                return False
1265
+
1266
+            def is_dir_error(
1267
+                self: pathlib.Path,
1268
+                /,
1269
+                *,
1270
+                follow_symlinks: bool = False,
1271
+            ) -> bool:
1272
+                del follow_symlinks
1273
+                raise OSError(
1274
+                    errno.EACCES,
1275
+                    os.strerror(errno.EACCES),
1276
+                    str(self),
1277
+                )
1278
+
1279
+            monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_false)
1280
+            assert cli_helpers.get_tempdir() == config_dir
1281
+
1282
+            monkeypatch.setattr(pathlib.Path, "is_dir", is_dir_error)
1283
+            assert cli_helpers.get_tempdir() == config_dir
1284
+
1285
+    @Parametrize.DELETE_CONFIG_INPUT
1286
+    def test_203_repeated_config_deletion(
1287
+        self,
1288
+        command_line: list[str],
1289
+        config: _types.VaultConfig,
1290
+        result_config: _types.VaultConfig,
1291
+    ) -> None:
1292
+        """Repeatedly removing the same parts of a configuration works."""
1293
+        for start_config in [config, result_config]:
1294
+            runner = machinery.CliRunner(mix_stderr=False)
1295
+            # TODO(the-13th-letter): Rewrite using parenthesized
1296
+            # with-statements.
1297
+            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1298
+            with contextlib.ExitStack() as stack:
1299
+                monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1300
+                stack.enter_context(
1301
+                    pytest_machinery.isolated_vault_config(
1302
+                        monkeypatch=monkeypatch,
1303
+                        runner=runner,
1304
+                        vault_config=start_config,
1305
+                    )
1306
+                )
1307
+                result = runner.invoke(
1308
+                    cli.derivepassphrase_vault,
1309
+                    command_line,
1310
+                    catch_exceptions=False,
1311
+                )
1312
+                assert result.clean_exit(empty_stderr=True), (
1313
+                    "expected clean exit"
1314
+                )
1315
+                with cli_helpers.config_filename(subsystem="vault").open(
1316
+                    encoding="UTF-8"
1317
+                ) as infile:
1318
+                    config_readback = json.load(infile)
1319
+                assert config_readback == result_config
1320
+
1321
+    def test_204_phrase_from_key_manually(self) -> None:
1322
+        """The dummy service, key and config settings are consistent."""
1323
+        assert (
1324
+            vault.Vault(
1325
+                phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS
1326
+            ).generate(DUMMY_SERVICE)
1327
+            == DUMMY_RESULT_KEY1
1328
+        )
1329
+
1330
+    @Parametrize.VALIDATION_FUNCTION_INPUT
1331
+    def test_210a_validate_constraints_manually(
1332
+        self,
1333
+        vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
1334
+        input: int,
1335
+    ) -> None:
1336
+        """Command-line argument constraint validation works."""
1337
+        ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, [])
1338
+        param = cli.derivepassphrase_vault.params[0]
1339
+        assert vfunc(ctx, param, input) == input
1340
+
1341
+    @Parametrize.CONNECTION_HINTS
1342
+    def test_227_get_suitable_ssh_keys(
1343
+        self,
1344
+        running_ssh_agent: data.RunningSSHAgentInfo,
1345
+        conn_hint: str,
1346
+    ) -> None:
1347
+        """[`cli_helpers.get_suitable_ssh_keys`][] works."""
1348
+        with pytest.MonkeyPatch.context() as monkeypatch:
1349
+            monkeypatch.setattr(
1350
+                ssh_agent.SSHAgentClient,
1351
+                "list_keys",
1352
+                callables.list_keys,
1353
+            )
1354
+            hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None
1355
+            # TODO(the-13th-letter): Rewrite using structural pattern
1356
+            # matching.
1357
+            # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1358
+            if conn_hint == "client":
1359
+                hint = ssh_agent.SSHAgentClient()
1360
+            elif conn_hint == "socket":
1361
+                if isinstance(
1362
+                    running_ssh_agent.socket, str
1363
+                ):  # pragma: no cover
1364
+                    if not hasattr(socket, "AF_UNIX"):
1365
+                        pytest.skip("socket module does not support AF_UNIX")
1366
+                    # socket.AF_UNIX is not defined everywhere.
1367
+                    hint = socket.socket(family=socket.AF_UNIX)  # type: ignore[attr-defined]
1368
+                    hint.connect(running_ssh_agent.socket)
1369
+                else:  # pragma: no cover
1370
+                    hint = running_ssh_agent.socket()
1371
+            else:
1372
+                assert conn_hint == "none"
1373
+                hint = None
1374
+            exception: Exception | None = None
1375
+            try:
1376
+                list(cli_helpers.get_suitable_ssh_keys(hint))
1377
+            except RuntimeError:  # pragma: no cover
1378
+                pass
1379
+            except Exception as e:  # noqa: BLE001 # pragma: no cover
1380
+                exception = e
1381
+            finally:
1382
+                assert exception is None, (
1383
+                    "exception querying suitable SSH keys"
1384
+                )
1385
+
1386
+    @Parametrize.KEY_TO_PHRASE_SETTINGS
1387
+    def test_400_key_to_phrase(
1388
+        self,
1389
+        ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
1390
+        list_keys_action: ListKeysAction | None,
1391
+        system_support_action: SystemSupportAction | None,
1392
+        address_action: SocketAddressAction | None,
1393
+        sign_action: SignAction,
1394
+        pattern: str,
1395
+    ) -> None:
1396
+        """All errors in [`cli_helpers.key_to_phrase`][] are handled."""
1397
+
1398
+        class ErrCallback(BaseException):
1399
+            def __init__(self, *args: Any, **kwargs: Any) -> None:
1400
+                super().__init__(*args[:1])
1401
+                self.args = args
1402
+                self.kwargs = kwargs
1403
+
1404
+        def err(*args: Any, **_kwargs: Any) -> NoReturn:
1405
+            raise ErrCallback(*args, **_kwargs)
1406
+
1407
+        with pytest.MonkeyPatch.context() as monkeypatch:
1408
+            loaded_keys = list(
1409
+                ssh_agent_client_with_test_keys_loaded.list_keys()
1410
+            )
1411
+            loaded_key = base64.standard_b64encode(loaded_keys[0][0])
1412
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, "sign", sign_action)
1413
+            if list_keys_action:
1414
+                monkeypatch.setattr(
1415
+                    ssh_agent.SSHAgentClient, "list_keys", list_keys_action
1416
+                )
1417
+            if address_action:
1418
+                address_action(monkeypatch)
1419
+            if system_support_action:
1420
+                system_support_action(monkeypatch)
1421
+            with pytest.raises(ErrCallback, match=pattern) as excinfo:
1422
+                cli_helpers.key_to_phrase(loaded_key, error_callback=err)
1423
+            if list_keys_action == ListKeysAction.FAIL_RUNTIME:
1424
+                assert excinfo.value.kwargs
1425
+                assert isinstance(
1426
+                    excinfo.value.kwargs["exc_info"],
1427
+                    ssh_agent.SSHAgentFailedError,
1428
+                )
1429
+                assert excinfo.value.kwargs["exc_info"].__context__ is not None
1430
+                assert isinstance(
1431
+                    excinfo.value.kwargs["exc_info"].__context__,
1432
+                    ssh_agent.TrailingDataError,
1433
+                )
0 1434