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 |