Add tests for the SSH agent socket provider machinery
Marco Ricci

Marco Ricci commited on 2025-08-02 13:50:27
Zeige 4 geänderte Dateien mit 1416 Einfügungen und 111 Löschungen.


We add the stub agent to the list of spawnable SSH agents, since it is
always available.  This necessitates a lot of cleanup to old test suite
functionality that assumes SSH agents are available if and only if UNIX
domain sockets are supported.  The `PERMITTED_SSH_AGENTS` environment
variable now controls which (external) SSH agents will be attempted to
be spawned, if supported by the system.  Additionally, the existing
agents that can be spawned are all UNIX-only, and correctly marked as
such; this avoids attempting to spawn Pageant on The Annoying Operating
System using a UNIX Pageant command-line.

We include a stub for the entry points list/table from
`importlib.metadata`, which sadly is rather ugly due to having to
reflect both an explicit API change in Python 3.12, and an undocumented
and hairy compatibility hack in Python 3.10.

We add a `SSHAgentSocketProviderRegistryStateMachine` class for testing
socket provider registrations via a `hypothesis` state machine, and
quite a few new entries in the `TestStaticFunctionality` class relating
to locating and registering socket providers on handcrafted explicit
examples.  I have resisted the temptation to implement all registry
tests as `hypothesis` tests, in favor of having at least *some* unit
tests left over to test the registry, even if skipping
`hypothesis`-based tests for speed reasons.

Finally, we split the monolithic test
`TestCLIUtils.test_400_key_to_phrase` from the CLI tests into
a parametrized series of smaller tests.
... ...
@@ -27,7 +27,7 @@ import click.testing
27 27
 import hypothesis
28 28
 import pytest
29 29
 from hypothesis import strategies
30
-from typing_extensions import NamedTuple, assert_never
30
+from typing_extensions import NamedTuple, assert_never, overload
31 31
 
32 32
 from derivepassphrase import _types, cli, ssh_agent, vault
33 33
 from derivepassphrase._internals import cli_helpers, cli_machinery
... ...
@@ -670,11 +670,19 @@ class RunningSSHAgentInfo(NamedTuple):
670 670
 
671 671
     """
672 672
 
673
-    socket: str
673
+    socket: str | type[_types.SSHAgentSocket]
674 674
     """"""
675 675
     agent_type: KnownSSHAgent
676 676
     """"""
677 677
 
678
+    def require_external_address(self) -> str:  # pragma: no cover
679
+        if not isinstance(self.socket, str):
680
+            pytest.skip(
681
+                reason='This test requires a real, externally resolvable '
682
+                'address for the SSH agent socket.'
683
+            )
684
+        return self.socket
685
+
678 686
 
679 687
 ALL_KEYS: Mapping[str, SSHTestKey] = {
680 688
     'ed25519': SSHTestKey(
... ...
@@ -2306,6 +2314,214 @@ def phrase_from_key(
2306 2314
     raise KeyError(key)  # pragma: no cover
2307 2315
 
2308 2316
 
2317
+def provider_entry_provider() -> _types.SSHAgentSocket:  # pragma: no cover
2318
+    """A pseudo provider for a [`_types.SSHAgentSocketProviderEntry`][]."""
2319
+    msg = 'We are not supposed to be called!'
2320
+    raise AssertionError(msg)
2321
+
2322
+
2323
+provider_entry1 = _types.SSHAgentSocketProviderEntry(
2324
+    provider_entry_provider, 'entry1', ('entry1a', 'entry1b', 'entry1c')
2325
+)
2326
+"""A sample [`_types.SSHAgentSocketProviderEntry`][]."""
2327
+
2328
+provider_entry2 = _types.SSHAgentSocketProviderEntry(
2329
+    provider_entry_provider, 'entry2', ('entry2d', 'entry2e')
2330
+)
2331
+"""A sample [`_types.SSHAgentSocketProviderEntry`][]."""
2332
+
2333
+posix_entry = _types.SSHAgentSocketProviderEntry(
2334
+    socketprovider.SocketProvider.resolve('posix'), 'posix', ()
2335
+)
2336
+"""
2337
+The standard [`_types.SSHAgentSocketProviderEntry`][] for the UNIX
2338
+domain socket handler on POSIX systems.
2339
+"""
2340
+
2341
+the_annoying_os_entry = _types.SSHAgentSocketProviderEntry(
2342
+    socketprovider.SocketProvider.resolve('the_annoying_os'),
2343
+    'the_annoying_os',
2344
+    (),
2345
+)
2346
+"""
2347
+The standard [`_types.SSHAgentSocketProviderEntry`][] for the named pipe
2348
+handler on The Annoying Operating System.
2349
+"""
2350
+
2351
+faulty_entry_callable = _types.SSHAgentSocketProviderEntry(
2352
+    (),  # type: ignore[arg-type]
2353
+    'tuple',
2354
+    (),
2355
+)
2356
+"""
2357
+A faulty [`_types.SSHAgentSocketProviderEntry`][]: the indicated handler
2358
+is not a callable.
2359
+"""
2360
+
2361
+faulty_entry_name_exists = _types.SSHAgentSocketProviderEntry(
2362
+    socketprovider.SocketProvider.resolve('the_annoying_os'), 'posix', ()
2363
+)
2364
+"""
2365
+A faulty [`_types.SSHAgentSocketProviderEntry`][]: the indicated handler
2366
+is already registered with a different callable.
2367
+"""
2368
+
2369
+faulty_entry_alias_exists = _types.SSHAgentSocketProviderEntry(
2370
+    socketprovider.SocketProvider.resolve('posix'),
2371
+    'posix',
2372
+    ('unix_domain', 'the_annoying_os'),
2373
+)
2374
+"""
2375
+A faulty [`_types.SSHAgentSocketProviderEntry`][]: the alias is already
2376
+registered with a different callable.
2377
+"""
2378
+
2379
+
2380
+@contextlib.contextmanager
2381
+def faked_entry_point_list(  # noqa: C901
2382
+    additional_entry_points: Sequence[importlib.metadata.EntryPoint],
2383
+    remove_conflicting_entries: bool = False,
2384
+) -> Iterator[Sequence[str]]:
2385
+    """Yield a context where additional entry points are visible.
2386
+
2387
+    Args:
2388
+        additional_entry_points:
2389
+            A sequence of entry point objects that should additionally
2390
+            be visible.
2391
+        remove_conflicting_entries:
2392
+            If true, remove all names provided by the additional entry
2393
+            points, otherwise leave them untouched.
2394
+
2395
+    Yields:
2396
+        A sequence of registry names that are newly available within the
2397
+        context.
2398
+
2399
+    """
2400
+    true_entry_points = importlib.metadata.entry_points()
2401
+    additional_entry_points = list(additional_entry_points)
2402
+
2403
+    if sys.version_info >= (3, 12):
2404
+        new_entry_points = importlib.metadata.EntryPoints(
2405
+            list(true_entry_points) + additional_entry_points
2406
+        )
2407
+
2408
+        @overload
2409
+        def mangled_entry_points(
2410
+            *, group: None = None
2411
+        ) -> importlib.metadata.EntryPoints: ...
2412
+
2413
+        @overload
2414
+        def mangled_entry_points(
2415
+            *, group: str
2416
+        ) -> importlib.metadata.EntryPoints: ...
2417
+
2418
+        def mangled_entry_points(
2419
+            **params: Any,
2420
+        ) -> importlib.metadata.EntryPoints:
2421
+            return new_entry_points.select(**params)
2422
+
2423
+    elif sys.version_info >= (3, 10):
2424
+        # Compatibility concerns within importlib.metadata: depending on
2425
+        # whether the .select() API is used, the result is either the dict
2426
+        # of groups of points (as in < 3.10), or the EntryPoints iterable
2427
+        # (as in >= 3.12).  So our wrapper needs to duplicate that
2428
+        # interface.  FUN.
2429
+        new_entry_points_dict = {
2430
+            k: list(v) for k, v in true_entry_points.items()
2431
+        }
2432
+        for ep in additional_entry_points:
2433
+            new_entry_points_dict.setdefault(ep.group, []).append(ep)
2434
+        new_entry_points = importlib.metadata.EntryPoints([
2435
+            ep for group in new_entry_points_dict.values() for ep in group
2436
+        ])
2437
+
2438
+        @overload
2439
+        def mangled_entry_points(
2440
+            *, group: None = None
2441
+        ) -> dict[
2442
+            str,
2443
+            list[importlib.metadata.EntryPoint]
2444
+            | tuple[importlib.metadata.EntryPoint, ...],
2445
+        ]: ...
2446
+
2447
+        @overload
2448
+        def mangled_entry_points(
2449
+            *, group: str
2450
+        ) -> importlib.metadata.EntryPoints: ...
2451
+
2452
+        def mangled_entry_points(
2453
+            **params: Any,
2454
+        ) -> (
2455
+            importlib.metadata.EntryPoints
2456
+            | dict[
2457
+                str,
2458
+                list[importlib.metadata.EntryPoint]
2459
+                | tuple[importlib.metadata.EntryPoint, ...],
2460
+            ]
2461
+        ):
2462
+            return (
2463
+                new_entry_points.select(**params)
2464
+                if params
2465
+                else new_entry_points_dict
2466
+            )
2467
+
2468
+    else:
2469
+        new_entry_points: dict[
2470
+            str,
2471
+            list[importlib.metadata.EntryPoint]
2472
+            | tuple[importlib.metadata.EntryPoint, ...],
2473
+        ] = {
2474
+            group_name: list(group)
2475
+            for group_name, group in true_entry_points.items()
2476
+        }
2477
+        for ep in additional_entry_points:
2478
+            new_entry_points.setdefault(ep.group, [])
2479
+            new_entry_points[ep.group].append(ep)
2480
+        new_entry_points = {
2481
+            group_name: tuple(group)
2482
+            for group_name, group in new_entry_points.items()
2483
+        }
2484
+
2485
+        @overload
2486
+        def mangled_entry_points(
2487
+            *, group: None = None
2488
+        ) -> dict[str, tuple[importlib.metadata.EntryPoint, ...]]: ...
2489
+
2490
+        @overload
2491
+        def mangled_entry_points(
2492
+            *, group: str
2493
+        ) -> tuple[importlib.metadata.EntryPoint, ...]: ...
2494
+
2495
+        def mangled_entry_points(
2496
+            *, group: str | None = None
2497
+        ) -> (
2498
+            dict[str, tuple[importlib.metadata.EntryPoint, ...]]
2499
+            | tuple[importlib.metadata.EntryPoint, ...]
2500
+        ):
2501
+            return (
2502
+                new_entry_points.get(group, ())
2503
+                if group is not None
2504
+                else new_entry_points
2505
+            )
2506
+
2507
+    registry = socketprovider.SocketProvider.registry
2508
+    new_registry = registry.copy()
2509
+    keys = [ep.load().key for ep in additional_entry_points]
2510
+    aliases = [a for ep in additional_entry_points for a in ep.load().aliases]
2511
+    if remove_conflicting_entries:  # pragma: no cover [unused]
2512
+        for name in [*keys, *aliases]:
2513
+            new_registry.pop(name, None)
2514
+
2515
+    with pytest.MonkeyPatch.context() as monkeypatch:
2516
+        monkeypatch.setattr(
2517
+            socketprovider.SocketProvider, 'registry', new_registry
2518
+        )
2519
+        monkeypatch.setattr(
2520
+            importlib.metadata, 'entry_points', mangled_entry_points
2521
+        )
2522
+        yield (*keys, *aliases)
2523
+
2524
+
2309 2525
 @contextlib.contextmanager
2310 2526
 def isolated_config(
2311 2527
     monkeypatch: pytest.MonkeyPatch,
... ...
@@ -2525,10 +2741,11 @@ def make_file_readonly(
2525 2741
     group and other, and ensuring the read permission bit for user is
2526 2742
     set.
2527 2743
 
2528
-    Unfortunately, Windows has its own rules: Set exactly(?) the read
2529
-    permission bit for user to make the file read-only, and set
2530
-    exactly(?) the write permission bit for user to make the file
2531
-    read/write; all other permission bit settings are ignored.
2744
+    Unfortunately, The Annoying OS (a.k.a. Microsoft Windows) has its
2745
+    own rules: Set exactly(?) the read permission bit for user to make
2746
+    the file read-only, and set exactly(?) the write permission bit for
2747
+    user to make the file read/write; all other permission bit settings
2748
+    are ignored.
2532 2749
 
2533 2750
     The cross-platform procedure therefore is:
2534 2751
 
... ...
@@ -8,7 +8,6 @@ import base64
8 8
 import contextlib
9 9
 import datetime
10 10
 import importlib
11
-import operator
12 11
 import os
13 12
 import shutil
14 13
 import socket
... ...
@@ -170,10 +169,10 @@ class SpawnFunc(Protocol):
170 169
         """
171 170
 
172 171
 
173
-def spawn_pageant(  # pragma: no cover [external]
172
+def spawn_pageant_on_posix(  # pragma: no cover [external]
174 173
     executable: str | None, env: dict[str, str]
175 174
 ) -> subprocess.Popen[str] | None:
176
-    """Spawn an isolated Pageant, if possible.
175
+    """Spawn an isolated (UNIX) Pageant, if possible.
177 176
 
178 177
     We attempt to detect whether Pageant is usable, i.e. whether Pageant
179 178
     has output buffering problems when announcing its authentication
... ...
@@ -195,7 +194,7 @@ def spawn_pageant(  # pragma: no cover [external]
195 194
         subprocess.
196 195
 
197 196
     """
198
-    if executable is None:  # pragma: no cover [external]
197
+    if executable is None or os.name == 'nt':  # pragma: no cover [external]
199 198
         return None
200 199
 
201 200
     # Apparently, Pageant 0.81 and lower running in debug mode does
... ...
@@ -242,10 +241,10 @@ def spawn_pageant(  # pragma: no cover [external]
242 241
     )
243 242
 
244 243
 
245
-def spawn_openssh_agent(  # pragma: no cover [external]
244
+def spawn_openssh_agent_on_posix(  # pragma: no cover [external]
246 245
     executable: str | None, env: dict[str, str]
247 246
 ) -> subprocess.Popen[str] | None:
248
-    """Spawn an isolated OpenSSH agent, if possible.
247
+    """Spawn an isolated OpenSSH agent (on UNIX), if possible.
249 248
 
250 249
     Args:
251 250
         executable:
... ...
@@ -262,7 +261,7 @@ def spawn_openssh_agent(  # pragma: no cover [external]
262 261
         subprocess.
263 262
 
264 263
     """
265
-    if executable is None:
264
+    if executable is None or os.name == 'nt':  # pragma: no cover [external]
266 265
         return None
267 266
     return subprocess.Popen(
268 267
         ['ssh-agent', '-D', '-s'],
... ...
@@ -282,11 +281,29 @@ def spawn_noop(  # pragma: no cover [unused]
282 281
     """Placeholder function. Does nothing."""
283 282
 
284 283
 
285
-spawn_handlers: Sequence[tuple[str, SpawnFunc, tests.KnownSSHAgent]] = [
286
-    ('pageant', spawn_pageant, tests.KnownSSHAgent.Pageant),
287
-    ('ssh-agent', spawn_openssh_agent, tests.KnownSSHAgent.OpenSSHAgent),
288
-    ('(system)', spawn_noop, tests.KnownSSHAgent.UNKNOWN),
289
-]
284
+spawn_handlers: dict[str, tuple[str, SpawnFunc, tests.KnownSSHAgent]] = {
285
+    'pageant': (
286
+        'pageant',
287
+        spawn_pageant_on_posix,
288
+        tests.KnownSSHAgent.Pageant,
289
+    ),
290
+    'ssh-agent': (
291
+        'ssh-agent',
292
+        spawn_openssh_agent_on_posix,
293
+        tests.KnownSSHAgent.OpenSSHAgent,
294
+    ),
295
+    'stub_agent': (
296
+        'stub_agent',
297
+        spawn_noop,
298
+        tests.KnownSSHAgent.StubbedSSHAgent,
299
+    ),
300
+    'stub_agent_with_extensions': (
301
+        'stub_agent_with_extensions',
302
+        spawn_noop,
303
+        tests.KnownSSHAgent.StubbedSSHAgent,
304
+    ),
305
+    '(system)': ('(system)', spawn_noop, tests.KnownSSHAgent.UNKNOWN),
306
+}
290 307
 """
291 308
 The standard registry of agent spawning functions.
292 309
 """
... ...
@@ -377,7 +394,12 @@ def spawn_named_agent(
377 394
     ssh_auth_sock = agent_env.pop('SSH_AUTH_SOCK', None)
378 395
     proc = spawn_func(executable=shutil.which(exec_name), env=agent_env)
379 396
     with exit_stack:
380
-        if spawn_func is spawn_noop:
397
+        if (
398
+            spawn_func is spawn_noop
399
+            and agent_type == tests.KnownSSHAgent.StubbedSSHAgent
400
+        ):
401
+            ssh_auth_sock = None
402
+        elif spawn_func is spawn_noop:
381 403
             ssh_auth_sock = os.environ['SSH_AUTH_SOCK']
382 404
         elif proc is None:  # pragma: no cover [external]
383 405
             err_msg = f'Cannot spawn usable {exec_name}'
... ...
@@ -404,11 +426,51 @@ def spawn_named_agent(
404 426
                 err_msg = f'Cannot parse agent output: {pid_line!r}'
405 427
                 raise CannotSpawnError(err_msg)
406 428
         monkeypatch = exit_stack.enter_context(pytest.MonkeyPatch.context())
429
+        if ssh_auth_sock is not None:
407 430
             monkeypatch.setenv('SSH_AUTH_SOCK', ssh_auth_sock)
408 431
             client = exit_stack.enter_context(
409 432
                 ssh_agent.SSHAgentClient.ensure_agent_subcontext()
410 433
             )
434
+        else:
435
+            monkeypatch.setenv(
436
+                'SSH_AUTH_SOCK', tests.StubbedSSHAgentSocketWithAddress.ADDRESS
437
+            )
438
+            monkeypatch.setattr(
439
+                ssh_agent.SSHAgentClient,
440
+                'SOCKET_PROVIDERS',
441
+                ['stub_with_address_and_deterministic_dsa']
442
+                if exec_name == 'stub_agent_with_extensions'
443
+                else ['stub_with_address'],
444
+            )
445
+            client = exit_stack.enter_context(
446
+                ssh_agent.SSHAgentClient.ensure_agent_subcontext(
447
+                    tests.StubbedSSHAgentSocketWithAddressAndDeterministicDSA()
448
+                    if exec_name == 'stub_agent_with_extensions'
449
+                    else tests.StubbedSSHAgentSocketWithAddress()
450
+                )
451
+            )
452
+        # We sanity-test the connected SSH agent if it is not one of our
453
+        # test agents, because allowing the user to run the test suite
454
+        # with a clearly faulty agent would likely and unfairly
455
+        # misattribute the agent's protocol violations to our test
456
+        # suite.  On the flip side, for our own test agents, the
457
+        # correctness tests are part of the test suite, so we don't want
458
+        # the *setup* code here to already decide that the agent is
459
+        # unsuitable. Therefore, do a sanity check if and only if the
460
+        # agent is not one of our test agents, and if the check fails,
461
+        # skip this agent.
462
+        if (
463
+            agent_type != tests.KnownSSHAgent.StubbedSSHAgent
464
+        ):  # pragma: no cover [external]
465
+            try:
411 466
                 client.list_keys()  # sanity test
467
+            except (
468
+                EOFError,
469
+                OSError,
470
+                ssh_agent.SSHAgentFailedError,
471
+            ) as exc:  # pragma: no cover [failsafe]
472
+                msg = f'agent failed the "list keys" sanity test: {exc!r}'
473
+                raise CannotSpawnError(msg) from exc
412 474
         yield tests.SpawnedSSHAgentInfo(
413 475
             agent_type, client, spawn_func is not spawn_noop
414 476
         )
... ...
@@ -417,9 +479,59 @@ def spawn_named_agent(
417 479
     )
418 480
 
419 481
 
482
+def is_agent_permitted(
483
+    agent_type: tests.KnownSSHAgent,
484
+) -> bool:  # pragma: no cover [external]
485
+    """May the given SSH agent be spawned by the test harness?
486
+
487
+    If the environment variable `PERMITTED_SSH_AGENTS` is given, it
488
+    names a comma-separated list of known SSH agent names that the test
489
+    harness may spawn.  Invalid names are silently ignored.  If not
490
+    given, or empty, then any agent may be spawned.
491
+
492
+    (To not allow any agent to be spawned, specify a single comma as the
493
+    list.  But see below.)
494
+
495
+    The stubbed agents cannot be restricted in this manner, as the test
496
+    suite depends on their availability.
497
+
498
+    """
499
+    if not os.environ.get('PERMITTED_SSH_AGENTS'):
500
+        return True
501
+    permitted_agents = {tests.KnownSSHAgent.StubbedSSHAgent}
502
+    permitted_agents.update({
503
+        tests.KnownSSHAgent(x)
504
+        for x in os.environ['PERMITTED_SSH_AGENTS'].split(',')
505
+        if x in tests.KnownSSHAgent.__members__
506
+    })
507
+    return agent_type in permitted_agents
508
+
509
+
510
+spawn_handlers_params: list[Sequence] = []
511
+"""
512
+The standard registry of agent spawning functions, annotated as
513
+a `pytest` parameter set.  (In particular, this includes the conditional
514
+skip marks.)  Used by some test fixtures.
515
+"""
516
+for key, handler in spawn_handlers.items():
517
+    marks = [
518
+        pytest.mark.skipif(
519
+            not is_agent_permitted(handler[2]),
520
+            reason='agent excluded via PERMITTED_AGENTS environment variable',
521
+        ),
522
+    ]
523
+    if key in {'pageant', 'ssh-agent', '(system)'}:
524
+        marks.append(
525
+            pytest.mark.skipif(
526
+                not hasattr(socket, 'AF_UNIX'),
527
+                reason='socket module does not support AF_UNIX',
528
+            )
529
+        )
530
+    spawn_handlers_params.append(pytest.param(handler, id=key, marks=marks))
531
+
532
+
420 533
 @pytest.fixture
421 534
 def running_ssh_agent(  # pragma: no cover [external]
422
-    skip_if_no_af_unix_support: None,
423 535
 ) -> Iterator[tests.RunningSSHAgentInfo]:
424 536
     """Ensure a running SSH agent, if possible, as a pytest fixture.
425 537
 
... ...
@@ -441,7 +553,30 @@ def running_ssh_agent(  # pragma: no cover [external]
441 553
             If no agent is running or can be spawned, skip this test.
442 554
 
443 555
     """
444
-    del skip_if_no_af_unix_support
556
+
557
+    def prepare_environment(
558
+        agent_type: tests.KnownSSHAgent,
559
+    ) -> Iterator[tests.RunningSSHAgentInfo]:
560
+        with pytest.MonkeyPatch.context() as monkeypatch:
561
+            if agent_type == tests.KnownSSHAgent.StubbedSSHAgent:
562
+                monkeypatch.setattr(
563
+                    ssh_agent.SSHAgentClient,
564
+                    'SOCKET_PROVIDERS',
565
+                    ['stub_with_address'],
566
+                )
567
+                monkeypatch.setenv(
568
+                    'SSH_AUTH_SOCK',
569
+                    tests.StubbedSSHAgentSocketWithAddress.ADDRESS,
570
+                )
571
+                yield tests.RunningSSHAgentInfo(
572
+                    tests.StubbedSSHAgentSocketWithAddress,
573
+                    tests.KnownSSHAgent.StubbedSSHAgent,
574
+                )
575
+            else:
576
+                yield tests.RunningSSHAgentInfo(
577
+                    os.environ['SSH_AUTH_SOCK'],
578
+                    agent_type,
579
+                )
445 580
 
446 581
     with pytest.MonkeyPatch.context() as monkeypatch:
447 582
         # pytest's fixture system does not seem to guarantee that
... ...
@@ -455,30 +590,30 @@ def running_ssh_agent(  # pragma: no cover [external]
455 590
             monkeypatch.setenv('SSH_AUTH_SOCK', startup_ssh_auth_sock)
456 591
         else:
457 592
             monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
458
-        for exec_name, spawn_func, agent_type in spawn_handlers:
593
+        for exec_name, spawn_func, agent_type in spawn_handlers.values():
594
+            if not is_agent_permitted(agent_type):
595
+                continue
459 596
             try:
460 597
                 for _agent_info in spawn_named_agent(
461 598
                     exec_name, spawn_func, agent_type
462 599
                 ):
463
-                    yield tests.RunningSSHAgentInfo(
464
-                        os.environ['SSH_AUTH_SOCK'], agent_type
465
-                    )
600
+                    yield from prepare_environment(agent_type)
466 601
             except (KeyError, OSError, CannotSpawnError):
467 602
                 continue
468 603
             return
469 604
         pytest.skip('No SSH agent running or spawnable')
470 605
 
471 606
 
472
-@pytest.fixture(params=spawn_handlers, ids=operator.itemgetter(0))
607
+@pytest.fixture(params=spawn_handlers_params)
473 608
 def spawn_ssh_agent(
474 609
     request: pytest.FixtureRequest,
475
-    skip_if_no_af_unix_support: None,
476 610
 ) -> Iterator[tests.SpawnedSSHAgentInfo]:  # pragma: no cover [external]
477 611
     """Spawn an isolated SSH agent, if possible, as a pytest fixture.
478 612
 
479 613
     Spawn a new SSH agent isolated from other SSH use by other
480
-    processes, if possible.  We know how to spawn OpenSSH's agent and
481
-    PuTTY's Pageant, and the "(system)" fallback agent.
614
+    processes, if possible.  We know how to spawn OpenSSH's agent (on
615
+    UNIX) and PuTTY's Pageant (on UNIX), the stubbed agents, and the
616
+    "(system)" fallback agent.
482 617
 
483 618
     Yields:
484 619
         A [named tuple][collections.namedtuple] containing information
... ...
@@ -491,7 +626,7 @@ def spawn_ssh_agent(
491 626
             If the agent cannot be spawned, skip this test.
492 627
 
493 628
     """
494
-    del skip_if_no_af_unix_support
629
+
495 630
     with pytest.MonkeyPatch.context() as monkeypatch:
496 631
         # pytest's fixture system does not seem to guarantee that
497 632
         # environment variables are set up correctly if nested and
... ...
@@ -550,6 +685,13 @@ def ssh_agent_client_with_test_keys_loaded(  # noqa: C901
550 685
     agent_type, client, isolated = spawn_ssh_agent
551 686
     successfully_loaded_keys: set[str] = set()
552 687
 
688
+    # This fixture relies on `spawn_ssh_agent`, which in turn uses
689
+    # `spawn_named_agent` to, well, spawn agents.  `spawn_named_agent`
690
+    # runs sanity tests on the agents it spawns, via
691
+    # `SSHAgentClient.list_keys`.  There is thus little point in
692
+    # repeating the very same sanity test here, on an agent that was
693
+    # already sanity-tested.
694
+
553 695
     def prepare_payload(
554 696
         payload: bytes | bytearray,
555 697
         *,
... ...
@@ -7,6 +7,7 @@ from __future__ import annotations
7 7
 import base64
8 8
 import contextlib
9 9
 import copy
10
+import ctypes
10 11
 import enum
11 12
 import errno
12 13
 import io
... ...
@@ -39,6 +40,7 @@ from derivepassphrase._internals import (
39 40
     cli_machinery,
40 41
     cli_messages,
41 42
 )
43
+from derivepassphrase.ssh_agent import socketprovider
42 44
 
43 45
 if TYPE_CHECKING:
44 46
     import multiprocessing
... ...
@@ -537,6 +539,258 @@ def zsh_format(item: click.shell_completion.CompletionItem) -> str:
537 539
     return f'{item.type}\n{value}\n{help_}'
538 540
 
539 541
 
542
+class ListKeysAction(str, enum.Enum):
543
+    """Test fixture settings for [`ssh_agent.SSHAgentClient.list_keys`][].
544
+
545
+    Attributes:
546
+        EMPTY: Return an empty key list.
547
+        FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
548
+        FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
549
+
550
+    """
551
+
552
+    EMPTY = enum.auto()
553
+    """"""
554
+    FAIL = enum.auto()
555
+    """"""
556
+    FAIL_RUNTIME = enum.auto()
557
+    """"""
558
+
559
+    def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
560
+        """Execute the respective action."""
561
+        # TODO(the-13th-letter): Rewrite using structural pattern
562
+        # matching.
563
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
564
+        if self == self.EMPTY:
565
+            return []
566
+        if self == self.FAIL:
567
+            raise ssh_agent.SSHAgentFailedError(
568
+                _types.SSH_AGENT.FAILURE.value, b''
569
+            )
570
+        if self == self.FAIL_RUNTIME:
571
+            raise ssh_agent.TrailingDataError()
572
+        raise AssertionError()
573
+
574
+
575
+class SignAction(str, enum.Enum):
576
+    """Test fixture settings for [`ssh_agent.SSHAgentClient.sign`][].
577
+
578
+    Attributes:
579
+        FAIL: Raise an [`ssh_agent.SSHAgentFailedError`][].
580
+        FAIL_RUNTIME: Raise an [`ssh_agent.TrailingDataError`][].
581
+
582
+    """
583
+
584
+    FAIL = enum.auto()
585
+    """"""
586
+    FAIL_RUNTIME = enum.auto()
587
+    """"""
588
+
589
+    def __call__(self, *_args: Any, **_kwargs: Any) -> Any:
590
+        """Execute the respective action."""
591
+        # TODO(the-13th-letter): Rewrite using structural pattern
592
+        # matching.
593
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
594
+        if self == self.FAIL:
595
+            raise ssh_agent.SSHAgentFailedError(
596
+                _types.SSH_AGENT.FAILURE.value, b''
597
+            )
598
+        if self == self.FAIL_RUNTIME:
599
+            raise ssh_agent.TrailingDataError()
600
+        raise AssertionError()
601
+
602
+
603
+class SocketAddressAction(str, enum.Enum):
604
+    """Test fixture settings for the SSH agent socket address.
605
+
606
+    Attributes:
607
+        MANGLE_ANNOYING_OS_NAMED_PIPE:
608
+            Mangle the address for the Annoying OS named pipe endpoint.
609
+        MANGLE_SSH_AUTH_SOCK:
610
+            Mangle the address for the UNIX domain socket (the
611
+            `SSH_AUTH_SOCK` environment variable).
612
+        UNSET_ANNOYING_OS_NAMED_PIPE:
613
+            Unset the address for the Annoying OS named pipe endpoint.
614
+        UNSET_SSH_AUTH_SOCK:
615
+            Unset the `SSH_AUTH_SOCK` environment variable (the address
616
+            for the UNIX domain socket).
617
+
618
+    """
619
+
620
+    MANGLE_ANNOYING_OS_NAMED_PIPE = enum.auto()
621
+    """"""
622
+    MANGLE_SSH_AUTH_SOCK = enum.auto()
623
+    """"""
624
+    UNSET_ANNOYING_OS_NAMED_PIPE = enum.auto()
625
+    """"""
626
+    UNSET_SSH_AUTH_SOCK = enum.auto()
627
+    """"""
628
+
629
+    def __call__(
630
+        self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
631
+    ) -> None:
632
+        """Execute the respective action."""
633
+        # TODO(the-13th-letter): Rewrite using structural pattern
634
+        # matching.
635
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
636
+        if self in {
637
+            self.MANGLE_ANNOYING_OS_NAMED_PIPE,
638
+            self.UNSET_ANNOYING_OS_NAMED_PIPE,
639
+        }:  # pragma: no cover [unused]
640
+            pass
641
+        elif self == self.MANGLE_SSH_AUTH_SOCK:
642
+            monkeypatch.setenv(
643
+                'SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~'
644
+            )
645
+        elif self == self.UNSET_SSH_AUTH_SOCK:
646
+            monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
647
+        else:
648
+            raise AssertionError()
649
+
650
+
651
+class SystemSupportAction(str, enum.Enum):
652
+    """Test fixture settings for [`ssh_agent.SSHAgentClient`][] system support.
653
+
654
+    Attributes:
655
+        UNSET_AF_UNIX:
656
+            Ensure lack of support for UNIX domain sockets.
657
+        UNSET_AF_UNIX_AND_ENSURE_USE:
658
+            Ensure lack of support for UNIX domain sockets, and that the
659
+            agent will use this socket provider.
660
+        UNSET_NATIVE:
661
+            Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`.
662
+        UNSET_NATIVE_AND_ENSURE_USE:
663
+            Ensure both `UNSET_AF_UNIX` and `UNSET_WINDLL`, and that the
664
+            agent will use the native socket provider.
665
+        UNSET_PROVIDER_LIST:
666
+            Ensure an empty list of SSH agent socket providers.
667
+        UNSET_WINDLL:
668
+            Ensure lack of support for The Annoying OS named pipes.
669
+        UNSET_WINDLL_AND_ENSURE_USE:
670
+            Ensure lack of support for The Annoying OS named pipes, and
671
+            that the agent will use this socket provider.
672
+
673
+    """
674
+
675
+    UNSET_AF_UNIX = enum.auto()
676
+    """"""
677
+    UNSET_AF_UNIX_AND_ENSURE_USE = enum.auto()
678
+    """"""
679
+    UNSET_NATIVE = enum.auto()
680
+    """"""
681
+    UNSET_NATIVE_AND_ENSURE_USE = enum.auto()
682
+    """"""
683
+    UNSET_PROVIDER_LIST = enum.auto()
684
+    """"""
685
+    UNSET_WINDLL = enum.auto()
686
+    """"""
687
+    UNSET_WINDLL_AND_ENSURE_USE = enum.auto()
688
+    """"""
689
+
690
+    def __call__(
691
+        self, monkeypatch: pytest.MonkeyPatch, /, *_args: Any, **_kwargs: Any
692
+    ) -> None:
693
+        """Execute the respective action.
694
+
695
+        Args:
696
+            monkeypatch: The current monkeypatch context.
697
+
698
+        """
699
+        # TODO(the-13th-letter): Rewrite using structural pattern
700
+        # matching.
701
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
702
+        if self == self.UNSET_PROVIDER_LIST:
703
+            monkeypatch.setattr(
704
+                ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', []
705
+            )
706
+        elif self in {self.UNSET_NATIVE, self.UNSET_NATIVE_AND_ENSURE_USE}:
707
+            self.check_or_ensure_use(
708
+                'native',
709
+                monkeypatch=monkeypatch,
710
+                ensure_use=(self == self.UNSET_NATIVE_AND_ENSURE_USE),
711
+            )
712
+            monkeypatch.delattr(socket, 'AF_UNIX', raising=False)
713
+            monkeypatch.delattr(ctypes, 'WinDLL', raising=False)
714
+            monkeypatch.delattr(ctypes, 'windll', raising=False)
715
+        elif self in {self.UNSET_AF_UNIX, self.UNSET_AF_UNIX_AND_ENSURE_USE}:
716
+            self.check_or_ensure_use(
717
+                'posix',
718
+                monkeypatch=monkeypatch,
719
+                ensure_use=(self == self.UNSET_AF_UNIX_AND_ENSURE_USE),
720
+            )
721
+            monkeypatch.delattr(socket, 'AF_UNIX', raising=False)
722
+        elif self in {self.UNSET_WINDLL, self.UNSET_WINDLL_AND_ENSURE_USE}:
723
+            self.check_or_ensure_use(
724
+                'the_annoying_os',
725
+                monkeypatch=monkeypatch,
726
+                ensure_use=(self == self.UNSET_WINDLL_AND_ENSURE_USE),
727
+            )
728
+            monkeypatch.delattr(ctypes, 'WinDLL', raising=False)
729
+            monkeypatch.delattr(ctypes, 'windll', raising=False)
730
+        else:
731
+            raise AssertionError()
732
+
733
+    @staticmethod
734
+    def check_or_ensure_use(
735
+        provider: str, /, *, monkeypatch: pytest.MonkeyPatch, ensure_use: bool
736
+    ) -> None:
737
+        """Check that the named SSH agent socket provider will be used.
738
+
739
+        Either ensure that the socket provider will definitely be used,
740
+        or, upon detecting that it won't be used, skip the test.
741
+
742
+        Args:
743
+            provider:
744
+                The provider to check for.
745
+            ensure_use:
746
+                If true, ensure that the socket provider will definitely
747
+                be used.  If false, then check for whether it will be
748
+                used, and skip this test if not.
749
+            monkeypatch:
750
+                The monkeypatch context within which the fixture
751
+                adjustments should be executed.
752
+
753
+        """
754
+        if ensure_use:
755
+            monkeypatch.setattr(
756
+                ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', [provider]
757
+            )
758
+        else:  # pragma: no cover [external]
759
+            # This branch operates completely on instrumented or on
760
+            # externally defined, non-deterministic state.
761
+            intended: (
762
+                _types.SSHAgentSocketProvider
763
+                | socketprovider.NoSuchProviderError
764
+                | None
765
+            )
766
+            try:
767
+                intended = socketprovider.SocketProvider.resolve(provider)
768
+            except socketprovider.NoSuchProviderError as exc:
769
+                intended = exc
770
+            except NotImplementedError:
771
+                intended = None
772
+            actual: (
773
+                _types.SSHAgentSocketProvider
774
+                | socketprovider.NoSuchProviderError
775
+                | None
776
+            )
777
+            for name in ssh_agent.SSHAgentClient.SOCKET_PROVIDERS:
778
+                try:
779
+                    actual = socketprovider.SocketProvider.resolve(name)
780
+                except socketprovider.NoSuchProviderError as exc:
781
+                    actual = exc
782
+                except NotImplementedError:
783
+                    continue
784
+                break
785
+            else:
786
+                actual = None
787
+            if intended != actual:
788
+                pytest.skip(
789
+                    f'{provider!r} SSH agent socket provider '
790
+                    f'is not currently in use'
791
+                )
792
+
793
+
540 794
 class Parametrize(types.SimpleNamespace):
541 795
     """Common test parametrizations."""
542 796
 
... ...
@@ -1308,6 +1562,97 @@ class Parametrize(types.SimpleNamespace):
1308 1562
     KEY_INDEX = pytest.mark.parametrize(
1309 1563
         'key_index', [1, 2, 3], ids=lambda i: f'index{i}'
1310 1564
     )
1565
+    KEY_TO_PHRASE_SETTINGS = pytest.mark.parametrize(
1566
+        [
1567
+            'list_keys_action',
1568
+            'address_action',
1569
+            'system_support_action',
1570
+            'sign_action',
1571
+            'pattern',
1572
+        ],
1573
+        [
1574
+            pytest.param(
1575
+                ListKeysAction.EMPTY,
1576
+                None,
1577
+                None,
1578
+                SignAction.FAIL,
1579
+                'not loaded into the agent',
1580
+                id='key-not-loaded',
1581
+            ),
1582
+            pytest.param(
1583
+                ListKeysAction.FAIL,
1584
+                None,
1585
+                None,
1586
+                SignAction.FAIL,
1587
+                'SSH agent failed to or refused to',
1588
+                id='list-keys-refused',
1589
+            ),
1590
+            pytest.param(
1591
+                ListKeysAction.FAIL_RUNTIME,
1592
+                None,
1593
+                None,
1594
+                SignAction.FAIL,
1595
+                'SSH agent failed to or refused to',
1596
+                id='list-keys-protocol-error',
1597
+            ),
1598
+            pytest.param(
1599
+                None,
1600
+                SocketAddressAction.UNSET_SSH_AUTH_SOCK,
1601
+                None,
1602
+                SignAction.FAIL,
1603
+                'Cannot find any running SSH agent',
1604
+                id='agent-address-missing',
1605
+            ),
1606
+            pytest.param(
1607
+                None,
1608
+                SocketAddressAction.MANGLE_SSH_AUTH_SOCK,
1609
+                None,
1610
+                SignAction.FAIL,
1611
+                'Cannot connect to the SSH agent',
1612
+                id='agent-address-mangled',
1613
+            ),
1614
+            pytest.param(
1615
+                None,
1616
+                None,
1617
+                SystemSupportAction.UNSET_NATIVE,
1618
+                SignAction.FAIL,
1619
+                'does not support communicating with it',
1620
+                id='no-agent-support',
1621
+            ),
1622
+            pytest.param(
1623
+                None,
1624
+                None,
1625
+                SystemSupportAction.UNSET_PROVIDER_LIST,
1626
+                SignAction.FAIL,
1627
+                'does not support communicating with it',
1628
+                id='no-agent-support',
1629
+            ),
1630
+            pytest.param(
1631
+                None,
1632
+                None,
1633
+                SystemSupportAction.UNSET_AF_UNIX_AND_ENSURE_USE,
1634
+                SignAction.FAIL,
1635
+                'does not support communicating with it',
1636
+                id='no-agent-support',
1637
+            ),
1638
+            pytest.param(
1639
+                None,
1640
+                None,
1641
+                SystemSupportAction.UNSET_WINDLL_AND_ENSURE_USE,
1642
+                SignAction.FAIL,
1643
+                'does not support communicating with it',
1644
+                id='no-agent-support',
1645
+            ),
1646
+            pytest.param(
1647
+                None,
1648
+                None,
1649
+                None,
1650
+                SignAction.FAIL_RUNTIME,
1651
+                'violates the communication protocol',
1652
+                id='sign-violates-protocol',
1653
+            ),
1654
+        ],
1655
+    )
1311 1656
     UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize(
1312 1657
         ['main_config', 'command_line', 'input', 'error_message'],
1313 1658
         [
... ...
@@ -2267,6 +2612,7 @@ class TestCLI:
2267 2612
         key_index: int,
2268 2613
     ) -> None:
2269 2614
         """A command-line SSH key will override the configured key."""
2615
+        del running_ssh_agent
2270 2616
         runner = tests.CliRunner(mix_stderr=False)
2271 2617
         # TODO(the-13th-letter): Rewrite using parenthesized
2272 2618
         # with-statements.
... ...
@@ -2280,7 +2626,6 @@ class TestCLI:
2280 2626
                     vault_config=config,
2281 2627
                 )
2282 2628
             )
2283
-            monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
2284 2629
             monkeypatch.setattr(
2285 2630
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2286 2631
             )
... ...
@@ -2302,6 +2647,7 @@ class TestCLI:
2302 2647
         running_ssh_agent: tests.RunningSSHAgentInfo,
2303 2648
     ) -> None:
2304 2649
         """A command-line passphrase will override the configured key."""
2650
+        del running_ssh_agent
2305 2651
         runner = tests.CliRunner(mix_stderr=False)
2306 2652
         # TODO(the-13th-letter): Rewrite using parenthesized
2307 2653
         # with-statements.
... ...
@@ -2323,7 +2669,6 @@ class TestCLI:
2323 2669
                     },
2324 2670
                 )
2325 2671
             )
2326
-            monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
2327 2672
             monkeypatch.setattr(
2328 2673
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2329 2674
             )
... ...
@@ -2352,6 +2697,7 @@ class TestCLI:
2352 2697
         command_line: list[str],
2353 2698
     ) -> None:
2354 2699
         """Configuring a passphrase atop an SSH key works, but warns."""
2700
+        del running_ssh_agent
2355 2701
         runner = tests.CliRunner(mix_stderr=False)
2356 2702
         # TODO(the-13th-letter): Rewrite using parenthesized
2357 2703
         # with-statements.
... ...
@@ -2365,7 +2711,6 @@ class TestCLI:
2365 2711
                     vault_config=config,
2366 2712
                 )
2367 2713
             )
2368
-            monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
2369 2714
             monkeypatch.setattr(
2370 2715
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2371 2716
             )
... ...
@@ -3629,8 +3974,10 @@ class TestCLI:
3629 3974
 
3630 3975
     def test_225a_store_config_fail_manual_no_ssh_key_selection(
3631 3976
         self,
3977
+        running_ssh_agent: tests.RunningSSHAgentInfo,
3632 3978
     ) -> None:
3633 3979
         """Not selecting an SSH key during `--config --key` fails."""
3980
+        del running_ssh_agent
3634 3981
         runner = tests.CliRunner(mix_stderr=False)
3635 3982
         # TODO(the-13th-letter): Rewrite using parenthesized
3636 3983
         # with-statements.
... ...
@@ -3644,27 +3991,33 @@ class TestCLI:
3644 3991
                     vault_config={'global': {'phrase': 'abc'}, 'services': {}},
3645 3992
                 )
3646 3993
             )
3647
-            custom_error = 'custom error message'
3648 3994
 
3649
-            def raiser(*_args: Any, **_kwargs: Any) -> None:
3650
-                raise RuntimeError(custom_error)
3995
+            def prompt_for_selection(*_args: Any, **_kwargs: Any) -> NoReturn:
3996
+                raise IndexError(cli_helpers.EMPTY_SELECTION)
3651 3997
 
3652
-            monkeypatch.setattr(cli_helpers, 'select_ssh_key', raiser)
3998
+            monkeypatch.setattr(
3999
+                cli_helpers, 'prompt_for_selection', prompt_for_selection
4000
+            )
4001
+            # Also patch the list of suitable SSH keys, lest we be at
4002
+            # the mercy of whatever SSH agent may be running.
4003
+            monkeypatch.setattr(
4004
+                cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys
4005
+            )
3653 4006
             result = runner.invoke(
3654 4007
                 cli.derivepassphrase_vault,
3655 4008
                 ['--key', '--config'],
3656 4009
                 catch_exceptions=False,
3657 4010
             )
3658
-        assert result.error_exit(error=custom_error), (
4011
+        assert result.error_exit(error='the user aborted the request'), (
3659 4012
             'expected error exit and known error message'
3660 4013
         )
3661 4014
 
3662 4015
     def test_225b_store_config_fail_manual_no_ssh_agent(
3663 4016
         self,
3664
-        skip_if_no_af_unix_support: None,
4017
+        running_ssh_agent: tests.RunningSSHAgentInfo,
3665 4018
     ) -> None:
3666 4019
         """Not running an SSH agent during `--config --key` fails."""
3667
-        del skip_if_no_af_unix_support
4020
+        del running_ssh_agent
3668 4021
         runner = tests.CliRunner(mix_stderr=False)
3669 4022
         # TODO(the-13th-letter): Rewrite using parenthesized
3670 4023
         # with-statements.
... ...
@@ -3693,7 +4046,7 @@ class TestCLI:
3693 4046
         running_ssh_agent: tests.RunningSSHAgentInfo,
3694 4047
     ) -> None:
3695 4048
         """Not running a reachable SSH agent during `--config --key` fails."""
3696
-        del running_ssh_agent
4049
+        running_ssh_agent.require_external_address()
3697 4050
         runner = tests.CliRunner(mix_stderr=False)
3698 4051
         # TODO(the-13th-letter): Rewrite using parenthesized
3699 4052
         # with-statements.
... ...
@@ -4273,6 +4626,7 @@ class TestCLI:
4273 4626
 
4274 4627
     def test_400_missing_af_unix_support(
4275 4628
         self,
4629
+        caplog: pytest.LogCaptureFixture,
4276 4630
     ) -> None:
4277 4631
         """Querying the SSH agent without `AF_UNIX` support fails."""
4278 4632
         runner = tests.CliRunner(mix_stderr=False)
... ...
@@ -4291,6 +4645,9 @@ class TestCLI:
4291 4645
             monkeypatch.setenv(
4292 4646
                 'SSH_AUTH_SOCK', "the value doesn't even matter"
4293 4647
             )
4648
+            monkeypatch.setattr(
4649
+                ssh_agent.SSHAgentClient, 'SOCKET_PROVIDERS', ['posix']
4650
+            )
4294 4651
             monkeypatch.delattr(socket, 'AF_UNIX', raising=False)
4295 4652
             result = runner.invoke(
4296 4653
                 cli.derivepassphrase_vault,
... ...
@@ -4300,6 +4657,10 @@ class TestCLI:
4300 4657
         assert result.error_exit(
4301 4658
             error='does not support communicating with it'
4302 4659
         ), 'expected error exit and known error message'
4660
+        assert tests.warning_emitted(
4661
+            'Cannot connect to an SSH agent via UNIX domain sockets',
4662
+            caplog.record_tuples,
4663
+        ), 'expected known warning message in stderr'
4303 4664
 
4304 4665
 
4305 4666
 class TestCLIUtils:
... ...
@@ -5122,20 +5483,26 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5122 5483
     ) -> None:
5123 5484
         """[`cli_helpers.get_suitable_ssh_keys`][] works."""
5124 5485
         with pytest.MonkeyPatch.context() as monkeypatch:
5125
-            monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
5126 5486
             monkeypatch.setattr(
5127 5487
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
5128 5488
             )
5129
-            hint: ssh_agent.SSHAgentClient | socket.socket | None
5489
+            hint: ssh_agent.SSHAgentClient | _types.SSHAgentSocket | None
5130 5490
             # TODO(the-13th-letter): Rewrite using structural pattern
5131 5491
             # matching.
5132 5492
             # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
5133 5493
             if conn_hint == 'client':
5134 5494
                 hint = ssh_agent.SSHAgentClient()
5135 5495
             elif conn_hint == 'socket':
5496
+                if isinstance(
5497
+                    running_ssh_agent.socket, str
5498
+                ):  # pragma: no cover
5499
+                    if not hasattr(socket, 'AF_UNIX'):
5500
+                        pytest.skip('socket module does not support AF_UNIX')
5136 5501
                     # socket.AF_UNIX is not defined everywhere.
5137 5502
                     hint = socket.socket(family=socket.AF_UNIX)  # type: ignore[attr-defined]
5138 5503
                     hint.connect(running_ssh_agent.socket)
5504
+                else:  # pragma: no cover
5505
+                    hint = running_ssh_agent.socket()
5139 5506
             else:
5140 5507
                 assert conn_hint == 'none'
5141 5508
                 hint = None
... ...
@@ -5151,10 +5518,15 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5151 5518
                     'exception querying suitable SSH keys'
5152 5519
                 )
5153 5520
 
5521
+    @Parametrize.KEY_TO_PHRASE_SETTINGS
5154 5522
     def test_400_key_to_phrase(
5155 5523
         self,
5156
-        skip_if_no_af_unix_support: None,
5157 5524
         ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient,
5525
+        list_keys_action: ListKeysAction | None,
5526
+        system_support_action: SystemSupportAction | None,
5527
+        address_action: SocketAddressAction | None,
5528
+        sign_action: SignAction,
5529
+        pattern: str,
5158 5530
     ) -> None:
5159 5531
         """All errors in [`cli_helpers.key_to_phrase`][] are handled."""
5160 5532
 
... ...
@@ -5167,44 +5539,23 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5167 5539
         def err(*args: Any, **_kwargs: Any) -> NoReturn:
5168 5540
             raise ErrCallback(*args, **_kwargs)
5169 5541
 
5170
-        def fail(*_args: Any, **_kwargs: Any) -> Any:
5171
-            raise ssh_agent.SSHAgentFailedError(
5172
-                _types.SSH_AGENT.FAILURE.value,
5173
-                b'',
5174
-            )
5175
-
5176
-        def fail_runtime(*_args: Any, **_kwargs: Any) -> Any:
5177
-            raise ssh_agent.TrailingDataError()
5178
-
5179
-        del skip_if_no_af_unix_support
5180 5542
         with pytest.MonkeyPatch.context() as monkeypatch:
5181
-            monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', fail)
5182 5543
             loaded_keys = list(
5183 5544
                 ssh_agent_client_with_test_keys_loaded.list_keys()
5184 5545
             )
5185 5546
             loaded_key = base64.standard_b64encode(loaded_keys[0][0])
5186
-            with monkeypatch.context() as mp:
5187
-                mp.setattr(
5188
-                    ssh_agent.SSHAgentClient,
5189
-                    'list_keys',
5190
-                    lambda *_a, **_kw: [],
5547
+            monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', sign_action)
5548
+            if list_keys_action:
5549
+                monkeypatch.setattr(
5550
+                    ssh_agent.SSHAgentClient, 'list_keys', list_keys_action
5191 5551
                 )
5192
-                with pytest.raises(
5193
-                    ErrCallback, match='not loaded into the agent'
5194
-                ):
5195
-                    cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5196
-            with monkeypatch.context() as mp:
5197
-                mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', fail)
5198
-                with pytest.raises(
5199
-                    ErrCallback, match='SSH agent failed to or refused to'
5200
-                ):
5201
-                    cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5202
-            with monkeypatch.context() as mp:
5203
-                mp.setattr(ssh_agent.SSHAgentClient, 'list_keys', fail_runtime)
5204
-                with pytest.raises(
5205
-                    ErrCallback, match='SSH agent failed to or refused to'
5206
-                ) as excinfo:
5552
+            if address_action:
5553
+                address_action(monkeypatch)
5554
+            if system_support_action:
5555
+                system_support_action(monkeypatch)
5556
+            with pytest.raises(ErrCallback, match=pattern) as excinfo:
5207 5557
                 cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5558
+            if list_keys_action == ListKeysAction.FAIL_RUNTIME:
5208 5559
                 assert excinfo.value.kwargs
5209 5560
                 assert isinstance(
5210 5561
                     excinfo.value.kwargs['exc_info'],
... ...
@@ -5215,30 +5566,6 @@ Will replace with spam, okay? (Please say "y" or "n".): Boo.
5215 5566
                     excinfo.value.kwargs['exc_info'].__context__,
5216 5567
                     ssh_agent.TrailingDataError,
5217 5568
                 )
5218
-            with monkeypatch.context() as mp:
5219
-                mp.delenv('SSH_AUTH_SOCK', raising=True)
5220
-                with pytest.raises(
5221
-                    ErrCallback, match='Cannot find any running SSH agent'
5222
-                ):
5223
-                    cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5224
-            with monkeypatch.context() as mp:
5225
-                mp.setenv('SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~')
5226
-                with pytest.raises(
5227
-                    ErrCallback, match='Cannot connect to the SSH agent'
5228
-                ):
5229
-                    cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5230
-            with monkeypatch.context() as mp:
5231
-                mp.delattr(socket, 'AF_UNIX', raising=True)
5232
-                with pytest.raises(
5233
-                    ErrCallback, match='does not support communicating with it'
5234
-                ):
5235
-                    cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5236
-            with monkeypatch.context() as mp:
5237
-                mp.setattr(ssh_agent.SSHAgentClient, 'sign', fail_runtime)
5238
-                with pytest.raises(
5239
-                    ErrCallback, match='violates the communication protocol'
5240
-                ):
5241
-                    cli_helpers.key_to_phrase(loaded_key, error_callback=err)
5242 5569
 
5243 5570
 
5244 5571
 # TODO(the-13th-letter): Remove this class in v1.0.
... ...
@@ -6654,7 +6981,7 @@ class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine):
6654 6981
         ] = {}
6655 6982
         true_configs: dict[int, _types.VaultConfig] = {}
6656 6983
         true_results: dict[int, tuple[bool, str | None, str | None]] = {}
6657
-        timeout = 5
6984
+        timeout = 30  # Hopefully slow enough to accomodate The Annoying OS.
6658 6985
         actions = self.actions
6659 6986
         mp = multiprocessing.get_context()
6660 6987
         # Coverage tracking writes coverage data to the current working
... ...
@@ -9,11 +9,13 @@ from __future__ import annotations
9 9
 import base64
10 10
 import contextlib
11 11
 import errno
12
+import importlib.metadata
12 13
 import io
13 14
 import os
14 15
 import pathlib
15 16
 import re
16 17
 import socket
18
+import sys
17 19
 import types
18 20
 from typing import TYPE_CHECKING
19 21
 
... ...
@@ -21,7 +23,7 @@ import click
21 23
 import click.testing
22 24
 import hypothesis
23 25
 import pytest
24
-from hypothesis import strategies
26
+from hypothesis import stateful, strategies
25 27
 
26 28
 import tests
27 29
 from derivepassphrase import _types, ssh_agent, vault
... ...
@@ -31,10 +33,83 @@ from derivepassphrase.ssh_agent import socketprovider
31 33
 if TYPE_CHECKING:
32 34
     from collections.abc import Iterable
33 35
 
34
-    from typing_extensions import Any, Buffer
36
+    from typing_extensions import Any, Buffer, Literal
37
+
38
+if sys.version_info < (3, 11):
39
+    from exceptiongroup import ExceptionGroup
35 40
 
36 41
 
37 42
 class Parametrize(types.SimpleNamespace):
43
+    BAD_ENTRY_POINTS = pytest.mark.parametrize(
44
+        'additional_entry_points',
45
+        [
46
+            pytest.param(
47
+                [
48
+                    importlib.metadata.EntryPoint(
49
+                        name=tests.faulty_entry_callable.key,
50
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
51
+                        value='tests: faulty_entry_callable',
52
+                    ),
53
+                ],
54
+                id='not-callable',
55
+            ),
56
+            pytest.param(
57
+                [
58
+                    importlib.metadata.EntryPoint(
59
+                        name=tests.faulty_entry_name_exists.key,
60
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
61
+                        value='tests: faulty_entry_name_exists',
62
+                    ),
63
+                ],
64
+                id='name-already-exists',
65
+            ),
66
+            pytest.param(
67
+                [
68
+                    importlib.metadata.EntryPoint(
69
+                        name=tests.faulty_entry_alias_exists.key,
70
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
71
+                        value='tests: faulty_entry_alias_exists',
72
+                    ),
73
+                ],
74
+                id='alias-already-exists',
75
+            ),
76
+        ],
77
+    )
78
+    GOOD_ENTRY_POINTS = pytest.mark.parametrize(
79
+        'additional_entry_points',
80
+        [
81
+            pytest.param(
82
+                [
83
+                    importlib.metadata.EntryPoint(
84
+                        name=tests.posix_entry.key,
85
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
86
+                        value='tests: posix_entry',
87
+                    ),
88
+                    importlib.metadata.EntryPoint(
89
+                        name=tests.the_annoying_os_entry.key,
90
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
91
+                        value='tests: the_annoying_os_entry',
92
+                    ),
93
+                ],
94
+                id='existing-entries',
95
+            ),
96
+            pytest.param(
97
+                [
98
+                    importlib.metadata.EntryPoint(
99
+                        name=tests.provider_entry1.key,
100
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
101
+                        value='tests: provider_entry1',
102
+                    ),
103
+                    importlib.metadata.EntryPoint(
104
+                        name=tests.provider_entry2.key,
105
+                        group=socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME,
106
+                        value='tests: provider_entry2',
107
+                    ),
108
+                ],
109
+                id='new-entries',
110
+            ),
111
+        ],
112
+    )
38 113
     STUBBED_AGENT_ADDRESSES = pytest.mark.parametrize(
39 114
         ['address', 'exception', 'match'],
40 115
         [
... ...
@@ -60,6 +135,9 @@ class Parametrize(types.SimpleNamespace):
60 135
             ),
61 136
         ],
62 137
     )
138
+    EXISTING_REGISTRY_ENTRIES = pytest.mark.parametrize(
139
+        'existing', ['posix', 'the_annoying_os']
140
+    )
63 141
     SSH_STRING_EXCEPTIONS = pytest.mark.parametrize(
64 142
         ['input', 'exc_type', 'exc_pattern'],
65 143
         [
... ...
@@ -414,6 +492,17 @@ class Parametrize(types.SimpleNamespace):
414 492
         list(tests.UNSUITABLE_KEYS.items()),
415 493
         ids=tests.UNSUITABLE_KEYS.keys(),
416 494
     )
495
+    RESOLVE_CHAINS = pytest.mark.parametrize(
496
+        ['terminal', 'chain'],
497
+        [
498
+            pytest.param('callable', ['a'], id='callable-1'),
499
+            pytest.param('callable', ['a', 'b', 'c', 'd'], id='callable-4'),
500
+            pytest.param('alias', ['e'], id='alias-5'),
501
+            pytest.param('alias', ['e', 'f', 'g', 'h', 'i'], id='alias-5'),
502
+            pytest.param('unimplemented', ['j'], id='unimplemented-1'),
503
+            pytest.param('unimplemented', ['j', 'k'], id='unimplemented-2'),
504
+        ],
505
+    )
417 506
 
418 507
 
419 508
 class TestTestingMachineryStubbedSSHAgentSocket:
... ...
@@ -709,18 +798,19 @@ class TestStaticFunctionality:
709 798
             with pytest.raises(ValueError, match='Cannot parse sh line:'):
710 799
                 tests.parse_sh_export_line(line, env_name=env_name)
711 800
 
712
-    def test_200_constructor_no_running_agent(
801
+    def test_200_constructor_posix_no_ssh_auth_sock(
713 802
         self,
714 803
         skip_if_no_af_unix_support: None,
715 804
     ) -> None:
716
-        """Abort if the running agent cannot be located."""
805
+        """Abort if the running agent cannot be located on POSIX."""
717 806
         del skip_if_no_af_unix_support
807
+        posix_handler = socketprovider.SocketProvider.resolve('posix')
718 808
         with pytest.MonkeyPatch.context() as monkeypatch:
719 809
             monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
720 810
             with pytest.raises(
721 811
                 KeyError, match='SSH_AUTH_SOCK environment variable'
722 812
             ):
723
-                ssh_agent.SSHAgentClient()
813
+                posix_handler()
724 814
 
725 815
     @Parametrize.UINT32_INPUT
726 816
     def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None:
... ...
@@ -831,6 +921,139 @@ class TestStaticFunctionality:
831 921
                 assert canon1(encoded) == canon2(encoded)
832 922
                 assert canon1(canon2(encoded)) == canon1(encoded)
833 923
 
924
+    def test_220_registry_resolve(
925
+        self,
926
+    ) -> None:
927
+        """Resolving entries in the socket provider registry works."""
928
+        registry = socketprovider.SocketProvider.registry
929
+        resolve = socketprovider.SocketProvider.resolve
930
+        with pytest.MonkeyPatch.context() as monkeypatch:
931
+            monkeypatch.setitem(registry, 'stub_agent', None)
932
+            assert callable(resolve('native'))
933
+            with pytest.raises(NotImplementedError):
934
+                resolve('stub_agent')
935
+
936
+    @Parametrize.RESOLVE_CHAINS
937
+    def test_221_registry_resolve_chains(
938
+        self,
939
+        terminal: Literal['unimplemented', 'alias', 'callable'],
940
+        chain: list[str],
941
+    ) -> None:
942
+        """Resolving a chain of providers works."""
943
+        registry = socketprovider.SocketProvider.registry
944
+        resolve = socketprovider.SocketProvider.resolve
945
+        try:
946
+            implementation = resolve('native')
947
+        except NotImplementedError:  # pragma: no cover
948
+            pytest.fail('Native SSH agent socket provider is unavailable?!')
949
+        # TODO(the-13th-letter): Rewrite using structural pattern matching.
950
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
951
+        target = (
952
+            None
953
+            if terminal == 'unimplemented'
954
+            else 'native'
955
+            if terminal == 'alias'
956
+            else implementation
957
+        )
958
+        with pytest.MonkeyPatch.context() as monkeypatch:
959
+            for link in chain:
960
+                monkeypatch.setitem(registry, link, target)
961
+                target = link
962
+            if terminal == 'unimplemented':
963
+                with pytest.raises(NotImplementedError):
964
+                    resolve(chain[-1])
965
+            else:
966
+                assert resolve(chain[-1]) == implementation
967
+
968
+    @hypothesis.given(
969
+        terminal=strategies.sampled_from([
970
+            'unimplemented',
971
+            'alias',
972
+            'callable',
973
+        ]),
974
+        chain=strategies.lists(
975
+            strategies.sampled_from([
976
+                'c1',
977
+                'c2',
978
+                'c3',
979
+                'c4',
980
+                'c5',
981
+                'c6',
982
+                'c7',
983
+                'c8',
984
+                'c9',
985
+                'c10',
986
+            ]),
987
+            min_size=1,
988
+            unique=True,
989
+        ),
990
+    )
991
+    def test_221a_registry_resolve_chains(
992
+        self,
993
+        terminal: Literal['unimplemented', 'alias', 'callable'],
994
+        chain: list[str],
995
+    ) -> None:
996
+        """Resolving a chain of providers works."""
997
+        registry = socketprovider.SocketProvider.registry
998
+        resolve = socketprovider.SocketProvider.resolve
999
+        try:
1000
+            implementation = resolve('native')
1001
+        except NotImplementedError:  # pragma: no cover
1002
+            hypothesis.note(f'{registry = }')
1003
+            pytest.fail('Native SSH agent socket provider is unavailable?!')
1004
+        # TODO(the-13th-letter): Rewrite using structural pattern matching.
1005
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1006
+        target = (
1007
+            None
1008
+            if terminal == 'unimplemented'
1009
+            else 'native'
1010
+            if terminal == 'alias'
1011
+            else implementation
1012
+        )
1013
+        with pytest.MonkeyPatch.context() as monkeypatch:
1014
+            for link in chain:
1015
+                monkeypatch.setitem(registry, link, target)
1016
+                target = link
1017
+            if terminal == 'unimplemented':
1018
+                with pytest.raises(NotImplementedError):
1019
+                    resolve(chain[-1])
1020
+            else:
1021
+                assert resolve(chain[-1]) == implementation
1022
+
1023
+    @Parametrize.GOOD_ENTRY_POINTS
1024
+    def test_230_find_all_socket_providers(
1025
+        self,
1026
+        additional_entry_points: list[importlib.metadata.EntryPoint],
1027
+    ) -> None:
1028
+        """Finding all SSH agent socket providers works."""
1029
+        resolve = socketprovider.SocketProvider.resolve
1030
+        old_registry = socketprovider.SocketProvider.registry
1031
+        with tests.faked_entry_point_list(
1032
+            additional_entry_points, remove_conflicting_entries=False
1033
+        ) as names:
1034
+            socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
1035
+            for name in names:
1036
+                assert name in socketprovider.SocketProvider.registry
1037
+                assert resolve(name) in {
1038
+                    tests.provider_entry_provider,
1039
+                    *old_registry.values(),
1040
+                }
1041
+
1042
+    @Parametrize.BAD_ENTRY_POINTS
1043
+    def test_231_find_all_socket_providers_errors(
1044
+        self,
1045
+        additional_entry_points: list[importlib.metadata.EntryPoint],
1046
+    ) -> None:
1047
+        """Finding faulty SSH agent socket providers raises errors."""
1048
+        with contextlib.ExitStack() as stack:
1049
+            stack.enter_context(
1050
+                tests.faked_entry_point_list(
1051
+                    additional_entry_points, remove_conflicting_entries=False
1052
+                )
1053
+            )
1054
+            stack.enter_context(pytest.raises(AssertionError))
1055
+            socketprovider.SocketProvider._find_all_ssh_agent_socket_providers()
1056
+
834 1057
     @Parametrize.UINT32_EXCEPTIONS
835 1058
     def test_310_uint32_exceptions(
836 1059
         self, input: int, exc_type: type[Exception], exc_pattern: str
... ...
@@ -869,6 +1092,123 @@ class TestStaticFunctionality:
869 1092
             with pytest.raises(exc_type, match=exc_pattern):
870 1093
                 unstring_prefix(input)
871 1094
 
1095
+    def test_320_registry_already_registered(
1096
+        self,
1097
+    ) -> None:
1098
+        """The registry forbids overwriting entries."""
1099
+        registry = socketprovider.SocketProvider.registry.copy()
1100
+        resolve = socketprovider.SocketProvider.resolve
1101
+        register = socketprovider.SocketProvider.register
1102
+        the_annoying_os = resolve('the_annoying_os')
1103
+        posix = resolve('posix')
1104
+        with pytest.MonkeyPatch.context() as monkeypatch:
1105
+            monkeypatch.setattr(
1106
+                socketprovider.SocketProvider, 'registry', registry
1107
+            )
1108
+            register('posix')(posix)
1109
+            register('the_annoying_os')(the_annoying_os)
1110
+            with pytest.raises(ValueError, match='already registered'):
1111
+                register('posix')(the_annoying_os)
1112
+            with pytest.raises(ValueError, match='already registered'):
1113
+                register('the_annoying_os')(posix)
1114
+            with pytest.raises(ValueError, match='already registered'):
1115
+                register('posix', 'the_annoying_os_named_pipe')(posix)
1116
+            with pytest.raises(ValueError, match='already registered'):
1117
+                register('the_annoying_os', 'unix_domain')(the_annoying_os)
1118
+
1119
+    def test_321_registry_resolve_non_existant_entries(
1120
+        self,
1121
+    ) -> None:
1122
+        """Resolving a non-existant entry fails."""
1123
+        new_registry = {
1124
+            'posix': socketprovider.SocketProvider.registry['posix'],
1125
+            'the_annoying_os': socketprovider.SocketProvider.registry[
1126
+                'the_annoying_os'
1127
+            ],
1128
+        }
1129
+        with pytest.MonkeyPatch.context() as monkeypatch:
1130
+            monkeypatch.setattr(
1131
+                socketprovider.SocketProvider, 'registry', new_registry
1132
+            )
1133
+            with pytest.raises(socketprovider.NoSuchProviderError):
1134
+                socketprovider.SocketProvider.resolve('native')
1135
+
1136
+    def test_322_registry_register_new_entry(
1137
+        self,
1138
+    ) -> None:
1139
+        """Registering new entries works."""
1140
+
1141
+        def socket_provider() -> _types.SSHAgentSocket:
1142
+            raise AssertionError
1143
+
1144
+        names = ['spam', 'ham', 'eggs', 'parrot']
1145
+        new_registry = {
1146
+            'posix': socketprovider.SocketProvider.registry['posix'],
1147
+            'the_annoying_os': socketprovider.SocketProvider.registry[
1148
+                'the_annoying_os'
1149
+            ],
1150
+        }
1151
+        with pytest.MonkeyPatch.context() as monkeypatch:
1152
+            monkeypatch.setattr(
1153
+                socketprovider.SocketProvider, 'registry', new_registry
1154
+            )
1155
+            assert not any(
1156
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1157
+            )
1158
+            assert (
1159
+                socketprovider.SocketProvider.register(*names)(socket_provider)
1160
+                is socket_provider
1161
+            )
1162
+            assert all(
1163
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1164
+            )
1165
+            assert all([
1166
+                socketprovider.SocketProvider.resolve(n) is socket_provider
1167
+                for n in names
1168
+            ])
1169
+
1170
+    @Parametrize.EXISTING_REGISTRY_ENTRIES
1171
+    def test_323_registry_register_old_entry(
1172
+        self,
1173
+        existing: str,
1174
+    ) -> None:
1175
+        """Registering old entries works."""
1176
+
1177
+        provider = socketprovider.SocketProvider.resolve(existing)
1178
+        new_registry = {
1179
+            'posix': socketprovider.SocketProvider.registry['posix'],
1180
+            'the_annoying_os': socketprovider.SocketProvider.registry[
1181
+                'the_annoying_os'
1182
+            ],
1183
+            'unix_domain': 'posix',
1184
+            'the_annoying_os_named_pipe': 'the_annoying_os',
1185
+        }
1186
+        names = [
1187
+            k
1188
+            for k, v in socketprovider.SocketProvider.registry.items()
1189
+            if v == existing
1190
+        ]
1191
+        with pytest.MonkeyPatch.context() as monkeypatch:
1192
+            monkeypatch.setattr(
1193
+                socketprovider.SocketProvider, 'registry', new_registry
1194
+            )
1195
+            assert not all(
1196
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1197
+            )
1198
+            assert (
1199
+                socketprovider.SocketProvider.register(existing, *names)(
1200
+                    provider
1201
+                )
1202
+                is provider
1203
+            )
1204
+            assert all(
1205
+                map(socketprovider.SocketProvider.registry.__contains__, names)
1206
+            )
1207
+            assert all([
1208
+                socketprovider.SocketProvider.resolve(n) is provider
1209
+                for n in [existing, *names]
1210
+            ])
1211
+
872 1212
 
873 1213
 class TestAgentInteraction:
874 1214
     """Test actually talking to the SSH agent."""
... ...
@@ -1012,7 +1352,7 @@ class TestAgentInteraction:
1012 1352
         @click.command()
1013 1353
         def driver() -> None:
1014 1354
             """Call [`cli_helpers.select_ssh_key`][] directly, as a command."""
1015
-            key = cli_helpers.select_ssh_key()
1355
+            key = cli_helpers.select_ssh_key(client)
1016 1356
             click.echo(base64.standard_b64encode(key).decode('ASCII'))
1017 1357
 
1018 1358
         # TODO(the-13th-letter): (Continued from above.)  Update input
... ...
@@ -1033,14 +1373,18 @@ class TestAgentInteraction:
1033 1373
     ) -> None:
1034 1374
         """Fail if the agent address is invalid."""
1035 1375
         with pytest.MonkeyPatch.context() as monkeypatch:
1036
-            monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket + '~')
1037
-            # socket.AF_UNIX is not defined everywhere.
1038
-            sock = socket.socket(family=socket.AF_UNIX)  # type: ignore[attr-defined]
1376
+            new_socket_name = (
1377
+                running_ssh_agent.socket + '~'
1378
+                if isinstance(running_ssh_agent.socket, str)
1379
+                else '<invalid//address>'
1380
+            )
1381
+            monkeypatch.setenv('SSH_AUTH_SOCK', new_socket_name)
1039 1382
             with pytest.raises(OSError):  # noqa: PT011
1040
-                ssh_agent.SSHAgentClient(socket=sock)
1383
+                ssh_agent.SSHAgentClient()
1041 1384
 
1042 1385
     def test_301_constructor_no_af_unix_support(self) -> None:
1043 1386
         """Fail without [`socket.AF_UNIX`][] support."""
1387
+        assert 'posix' in socketprovider.SocketProvider.registry
1044 1388
         with pytest.MonkeyPatch.context() as monkeypatch:
1045 1389
             monkeypatch.setenv('SSH_AUTH_SOCK', "the value doesn't matter")
1046 1390
             monkeypatch.delattr(socket, 'AF_UNIX', raising=False)
... ...
@@ -1048,7 +1392,31 @@ class TestAgentInteraction:
1048 1392
                 NotImplementedError,
1049 1393
                 match='UNIX domain sockets',
1050 1394
             ):
1051
-                ssh_agent.SSHAgentClient()
1395
+                ssh_agent.SSHAgentClient(socket='posix')
1396
+
1397
+    def test_302_no_ssh_agent_socket_provider_available(
1398
+        self,
1399
+    ) -> None:
1400
+        """Fail if no SSH agent socket provider is available."""
1401
+        with pytest.MonkeyPatch.context() as monkeypatch:
1402
+            monkeypatch.setitem(
1403
+                socketprovider.SocketProvider.registry, 'stub_agent', None
1404
+            )
1405
+            with pytest.raises(ExceptionGroup) as excinfo:
1406
+                ssh_agent.SSHAgentClient(
1407
+                    socket=['stub_agent', 'stub_agent', 'stub_agent']
1408
+                )
1409
+            assert all([
1410
+                isinstance(e, NotImplementedError)
1411
+                for e in excinfo.value.exceptions
1412
+            ])
1413
+
1414
+    def test_303_explicit_socket(
1415
+        self,
1416
+        spawn_ssh_agent: tests.SpawnedSSHAgentInfo,
1417
+    ) -> None:
1418
+        conn = spawn_ssh_agent.client._connection
1419
+        ssh_agent.SSHAgentClient(socket=conn)
1052 1420
 
1053 1421
     @Parametrize.TRUNCATED_AGENT_RESPONSES
1054 1422
     def test_310_truncated_server_response(
... ...
@@ -1294,3 +1662,254 @@ class TestAgentInteraction:
1294 1662
                 match=r'Malformed response|does not match request',
1295 1663
             ):
1296 1664
                 client.query_extensions()
1665
+
1666
+
1667
+def safe_resolve(name: str) -> _types.SSHAgentSocketProvider | None:
1668
+    """Safely resolve an SSH agent socket provider name.
1669
+
1670
+    If the provider is merely reserved, return `None` instead of raising
1671
+    an exception.  Otherwise behave like
1672
+    [`socketprovider.SocketProvider.resolve`][] does.
1673
+
1674
+    Args:
1675
+        name: The name of the provider to resolve.
1676
+
1677
+    Returns:
1678
+        The SSH agent socket provider if registered, `None` if merely
1679
+        reserved, and an error if not found.
1680
+
1681
+    Raises:
1682
+        socketprovider.NoSuchProviderError:
1683
+            No such provider was found in the registry.  This includes
1684
+            entries that are merely reserved.
1685
+
1686
+    """
1687
+    try:
1688
+        return socketprovider.SocketProvider.resolve(name)
1689
+    except NotImplementedError:  # pragma: no cover
1690
+        return None
1691
+
1692
+
1693
+@strategies.composite
1694
+def draw_alias_chain(
1695
+    draw: strategies.DrawFn,
1696
+    *,
1697
+    known_keys_strategy: strategies.SearchStrategy[str],
1698
+    new_keys_strategy: strategies.SearchStrategy[str],
1699
+    chain_size: strategies.SearchStrategy[int] = strategies.integers(  # noqa: B008
1700
+        min_value=1,
1701
+        max_value=5,
1702
+    ),
1703
+    existing: bool = False,
1704
+) -> tuple[str, ...]:
1705
+    """Draw names for alias chains in the SSH agent socket provider registry.
1706
+
1707
+    Depending on arguments, draw a set of names from the new keys bundle
1708
+    that do not yet exist in the registry, to insert as a new alias
1709
+    chain.  Alternatively, draw a non-alias name from the known keys
1710
+    bundle, then draw other names that either don't exist yet in the
1711
+    registry, or that alias the first name directly or indirectly.  The
1712
+    chain length, and whether to target existing registry entries or
1713
+    not, may be set statically, or may be drawn from a respective
1714
+    strategy.
1715
+
1716
+    Args:
1717
+        draw:
1718
+            The `hypothesis` draw function.
1719
+        chain_size:
1720
+            A strategy for determining the correct alias chain length.
1721
+            Must not yield any integers less than 1.
1722
+        existing:
1723
+            If true, target an existing registry entry in the alias
1724
+            chain, and permit rewriting existing aliases of that same
1725
+            entry to the new alias.  Otherwise, draw only new names.
1726
+        known_keys_strategy:
1727
+            A strategy for generating provider registry keys already
1728
+            contained in the registry.  Typically, this is
1729
+            a [Bundle][hypothesis.stateful.Bundle].
1730
+        new_keys_strategy:
1731
+            A strategy for generating provider registry keys not yet
1732
+            contained in the registry with high probability.  Typically,
1733
+            this is a [consuming][hypothesis.stateful.consumes]
1734
+            [Bundle][hypothesis.stateful.Bundle].
1735
+
1736
+    Returns:
1737
+        A tuple of names forming an alias chain, each entry pointing to
1738
+        or intending to point to the previous entry in the tuple.
1739
+
1740
+    """
1741
+    registry = socketprovider.SocketProvider.registry
1742
+
1743
+    def not_an_alias(key: str) -> bool:
1744
+        return key in registry and not isinstance(registry[key], str)
1745
+
1746
+    def is_indirect_alias_of(
1747
+        key: str, target: str
1748
+    ) -> bool:  # pragma: no cover
1749
+        if key == target:
1750
+            return False  # not an alias
1751
+        seen = set()  # loop detection
1752
+        while key not in seen:
1753
+            seen.add(key)
1754
+            if key not in registry:
1755
+                return False
1756
+            if not isinstance(registry[key], str):
1757
+                return False
1758
+            if key == target:
1759
+                return True
1760
+            tmp = registry[key]
1761
+            assert isinstance(tmp, str)
1762
+            key = tmp
1763
+        return False  # loop
1764
+
1765
+    err_msg_chain_size = 'Chain sizes must always be 1 or larger.'
1766
+
1767
+    size = draw(chain_size)
1768
+    if size < 1:  # pragma: no cover
1769
+        raise ValueError(err_msg_chain_size)
1770
+    names: list[str] = []
1771
+    base: str | None = None
1772
+    if existing:
1773
+        names.append(draw(known_keys_strategy.filter(not_an_alias)))
1774
+        base = names[0]
1775
+        size -= 1
1776
+        new_key_strategy = new_keys_strategy.filter(
1777
+            lambda key: key not in registry
1778
+        )
1779
+        old_key_strategy = known_keys_strategy.filter(
1780
+            lambda key: is_indirect_alias_of(key, target=base)
1781
+        )
1782
+        list_strategy_source = strategies.one_of(
1783
+            new_key_strategy, old_key_strategy
1784
+        )
1785
+    else:
1786
+        list_strategy_source = new_keys_strategy.filter(
1787
+            lambda key: key not in registry
1788
+        )
1789
+    list_strategy = strategies.lists(
1790
+        list_strategy_source.filter(lambda candidate: candidate != base),
1791
+        min_size=size,
1792
+        max_size=size,
1793
+        unique=True,
1794
+    )
1795
+    names.extend(draw(list_strategy))
1796
+    return tuple(names)
1797
+
1798
+
1799
+class SSHAgentSocketProviderRegistryStateMachine(
1800
+    stateful.RuleBasedStateMachine
1801
+):
1802
+    """A state machine for the SSH agent socket provider registry.
1803
+
1804
+    Record possible changes to the socket provider registry, keeping track
1805
+    of true entries, aliases, and reservations.
1806
+
1807
+    """
1808
+
1809
+    def __init__(self) -> None:
1810
+        """Initialize self, set up context managers and enter them."""
1811
+        super().__init__()
1812
+        self.exit_stack = contextlib.ExitStack().__enter__()
1813
+        self.monkeypatch = self.exit_stack.enter_context(
1814
+            pytest.MonkeyPatch.context()
1815
+        )
1816
+        self.orig_registry = socketprovider.SocketProvider.registry
1817
+        self.registry: dict[
1818
+            str, _types.SSHAgentSocketProvider | str | None
1819
+        ] = {
1820
+            'posix': self.orig_registry['posix'],
1821
+            'the_annoying_os': self.orig_registry['the_annoying_os'],
1822
+            'native': self.orig_registry['native'],
1823
+            'unix_domain': 'posix',
1824
+            'the_annoying_os_named_pipe': 'the_annoying_os',
1825
+        }
1826
+        self.monkeypatch.setattr(
1827
+            socketprovider.SocketProvider, 'registry', self.registry
1828
+        )
1829
+        self.model: dict[str, _types.SSHAgentSocketProvider | None] = {}
1830
+
1831
+    known_keys: stateful.Bundle[str] = stateful.Bundle('known_keys')
1832
+    """"""
1833
+    new_keys: stateful.Bundle[str] = stateful.Bundle('new_keys')
1834
+    """"""
1835
+
1836
+    def sample_provider(self) -> _types.SSHAgentSocket:
1837
+        raise AssertionError
1838
+
1839
+    @stateful.initialize(
1840
+        target=known_keys,
1841
+    )
1842
+    def get_registry_keys(self) -> stateful.MultipleResults[str]:
1843
+        """Read the standard keys from the registry."""
1844
+        self.model.update({k: safe_resolve(k) for k in self.registry})
1845
+        return stateful.multiple(*self.registry.keys())
1846
+
1847
+    @stateful.rule(
1848
+        target=new_keys,
1849
+        k=strategies.text('abcdefghijklmnopqrstuvwxyz0123456789_').filter(
1850
+            lambda s: s not in socketprovider.SocketProvider.registry
1851
+        ),
1852
+    )
1853
+    def new_key(self, k: str) -> str:
1854
+        return k
1855
+
1856
+    @stateful.invariant()
1857
+    def check_consistency(self) -> None:
1858
+        assert self.registry.keys() == self.model.keys()
1859
+        for k in self.model:
1860
+            resolved = safe_resolve(k)
1861
+            modelled = self.model[k]
1862
+            step1 = self.registry[k]
1863
+            manually = safe_resolve(step1) if isinstance(step1, str) else step1
1864
+            assert resolved == modelled
1865
+            assert resolved == manually
1866
+
1867
+    @stateful.rule(
1868
+        target=known_keys,
1869
+        chain=draw_alias_chain(
1870
+            known_keys_strategy=known_keys,
1871
+            new_keys_strategy=stateful.consumes(new_keys),
1872
+            existing=True,
1873
+        ),
1874
+    )
1875
+    def alias_existing(
1876
+        self, chain: tuple[str, ...]
1877
+    ) -> stateful.MultipleResults[str]:
1878
+        try:
1879
+            provider = socketprovider.SocketProvider.resolve(chain[0])
1880
+        except NotImplementedError:  # pragma: no cover [failsafe]
1881
+            provider = self.sample_provider
1882
+        assert (
1883
+            socketprovider.SocketProvider.register(*chain)(provider)
1884
+            == provider
1885
+        )
1886
+        for k in chain:
1887
+            self.model[k] = provider
1888
+        return stateful.multiple(*chain[1:])
1889
+
1890
+    @stateful.rule(
1891
+        target=known_keys,
1892
+        chain=draw_alias_chain(
1893
+            known_keys_strategy=known_keys,
1894
+            new_keys_strategy=stateful.consumes(new_keys),
1895
+            existing=False,
1896
+        ),
1897
+    )
1898
+    def alias_new(self, chain: list[str]) -> stateful.MultipleResults[str]:
1899
+        provider = self.sample_provider
1900
+        assert (
1901
+            socketprovider.SocketProvider.register(*chain)(provider)
1902
+            == provider
1903
+        )
1904
+        for k in chain:
1905
+            self.model[k] = provider
1906
+        return stateful.multiple(*chain)
1907
+
1908
+    def teardown(self) -> None:
1909
+        """Upon teardown, exit all contexts entered in `__init__`."""
1910
+        self.exit_stack.close()
1911
+
1912
+
1913
+TestSSHAgentSocketProviderRegistry = (
1914
+    SSHAgentSocketProviderRegistryStateMachine.TestCase
1915
+)
1297 1916