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 |