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 |