Coverage for tests / conftest.py: 100.000%
92 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5from __future__ import annotations
7import base64
8import contextlib
9import ctypes
10import os
11import shutil
12import socket
13import subprocess
14from typing import TYPE_CHECKING, Any, Protocol, TypeVar
16import packaging.version
17import pytest
18from typing_extensions import NamedTuple
20from derivepassphrase import _types, ssh_agent
21from derivepassphrase.ssh_agent import socketprovider
22from tests import data, machinery
23from tests.data import callables
24from tests.machinery import hypothesis as hypothesis_machinery
25from tests.machinery import pytest as pytest_machinery
27if TYPE_CHECKING:
28 from collections.abc import Generator, Sequence
30startup_ssh_auth_sock = os.environ.get("SSH_AUTH_SOCK", None)
33def pytest_configure(config: pytest.Config) -> None:
34 """Configure `pytest`: add custom markers."""
35 config.addinivalue_line(
36 "markers", ("heavy_duty: mark test as a slow, heavy-duty test")
37 )
38 config.addinivalue_line(
39 "markers",
40 (
41 "correctness_focused: "
42 "mark test as a potentially slow, correctness-focused test"
43 ),
44 )
45 config.addinivalue_line(
46 "markers",
47 (
48 "external_agent: "
49 "mark the agent (fixture output) as external to the test suite"
50 ),
51 )
52 config.addinivalue_line(
53 "markers",
54 (
55 "non_reentrant_agent: "
56 "mark the agent (fixture output) as non-reentrant"
57 ),
58 )
59 hypothesis_machinery._hypothesis_settings_setup()
62# https://docs.pytest.org/en/stable/explanation/fixtures.html#a-note-about-fixture-cleanup
63# https://github.com/pytest-dev/pytest/issues/5243#issuecomment-491522595
64@pytest.fixture(scope="session", autouse=False)
65def term_handler() -> Generator[
66 None, None, None
67]: # pragma: no cover [external]
68 try:
69 import signal # noqa: PLC0415
71 sigint_handler = signal.getsignal(signal.SIGINT)
72 except (ImportError, OSError):
73 return
74 else:
75 orig_term = signal.signal(signal.SIGTERM, sigint_handler)
76 yield
77 signal.signal(signal.SIGTERM, orig_term)
80@pytest.fixture(scope="session")
81def skip_if_no_af_unix_support() -> None: # pragma: no cover [external]
82 """Skip the test if Python does not support `AF_UNIX`.
84 Implemented as a fixture instead of a mark because another "autouse"
85 session fixture may want to force/simulate non-support of
86 [`socket.AF_UNIX`][].
88 """
89 if not hasattr(socket, "AF_UNIX"):
90 pytest.skip("No Python or system support for UNIX domain sockets.")
93@pytest.fixture(scope="session")
94def skip_if_no_ctypes_windll_support() -> None: # pragma: no cover [external]
95 """Skip the test if Python does not support WinDLL in `ctypes`.
97 Implemented as a fixture instead of a mark because another "autouse"
98 session fixture may want to force/simulate non-support of
99 [`ctypes.WinDLL`][].
101 """
102 if not hasattr(ctypes, "WinDLL"):
103 pytest.skip(
104 "No Python or system support for interfacing Windows DLLs."
105 )
108@pytest.fixture
109def use_stub_agent() -> Generator[None, None, None]:
110 """Enforce use of the stubbed SSH agent."""
111 with pytest.MonkeyPatch.context() as monkeypatch:
112 monkeypatch.setattr(
113 ssh_agent.SSHAgentClient,
114 "SOCKET_PROVIDERS",
115 (_types.BuiltinSSHAgentSocketProvider.STUB_AGENT,),
116 )
117 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
118 yield
121@pytest.fixture
122def use_stub_agent_with_address() -> Generator[None, None, None]:
123 """Enforce use of the stub SSH agent with socket address."""
124 with pytest.MonkeyPatch.context() as monkeypatch:
125 monkeypatch.setattr(
126 ssh_agent.SSHAgentClient,
127 "SOCKET_PROVIDERS",
128 (_types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS,),
129 )
130 monkeypatch.setenv(
131 "SSH_AUTH_SOCK", machinery.StubbedSSHAgentSocketWithAddress.ADDRESS
132 )
133 yield
136class SSHAgentSpawnFunc(Protocol):
137 """Spawns an SSH agent, if possible."""
139 def __call__(
140 self,
141 executable: str | None,
142 env: dict[str, str],
143 ) -> subprocess.Popen[str] | None:
144 """Spawn the SSH agent.
146 Args:
147 executable:
148 The respective SSH agent executable.
149 env:
150 The new environment for the respective agent. Should
151 typically not include an SSH_AUTH_SOCK variable.
153 Returns:
154 The spawned SSH agent subprocess. If the executable is
155 `None`, then return `None` directly.
157 It is the caller's responsibility to clean up the spawned
158 subprocess.
160 Raises:
161 OSError:
162 The [`subprocess.Popen`][] call failed. See there.
164 """
167class SSHAgentInterfaceFunc(Protocol):
168 """Interface an SSH agent, if possible."""
170 def __call__(
171 self,
172 executable: str | None,
173 env: dict[str, Any],
174 ) -> tuple[_types.SSHAgentSocket, str] | None:
175 """Interface the SSH agent.
177 Args:
178 env:
179 A configuration environment to interface the agent.
180 Will typically include some kind of address setting
181 detailing how to communicate with the agent. The
182 exact contents depend on the specific agent.
184 Other Args:
185 executable:
186 (Ignored. Not relevant for interfacing functions.)
188 Returns:
189 A 2-tuple, if possible, containing an abstract socket
190 connected to the specified SSH agent and the associated
191 address; otherwise, `None`.
193 It is the caller's responsibility to clean up the socket
194 (through use of the context manager protocol).
196 """
199def spawn_pageant_on_posix( # pragma: no cover [external]
200 executable: str | None, env: dict[str, str]
201) -> subprocess.Popen[str] | None:
202 """Spawn an isolated (UNIX) Pageant, if possible.
204 We attempt to detect whether Pageant is usable, i.e. whether Pageant
205 has output buffering problems when announcing its authentication
206 socket. This is the case for Pageant 0.81 and earlier.
208 Args:
209 executable:
210 The path to the Pageant executable.
211 env:
212 The new environment for Pageant. Should typically not
213 include an SSH_AUTH_SOCK variable.
215 Returns:
216 The spawned Pageant subprocess. If the executable is `None`, or
217 if we detect that Pageant cannot be sensibly controlled as
218 a subprocess, then return `None` directly.
220 It is the caller's responsibility to clean up the spawned
221 subprocess.
223 """
224 if os.name == "nt": # pragma: no cover [external]
225 return None
226 if executable is None: # pragma: no cover [external]
227 executable = shutil.which("pageant")
228 if executable is None:
229 return None
231 # Apparently, Pageant 0.81 and lower running in debug mode does
232 # not actively flush its output. As a result, the first two
233 # lines, which set the SSH_AUTH_SOCK and the SSH_AGENT_PID, only
234 # print once the output buffer is flushed, whenever that is.
235 #
236 # This has been reported to the PuTTY developers. It is fixed in
237 # version 0.82, though the PuTTY developers consider this to be an
238 # abuse of debug mode. A new foreground mode (`--foreground`), also
239 # introduced in 0.82, provides the desired behavior: no forking, and
240 # immediately parsable instructions for SSH_AUTH_SOCK and
241 # SSH_AGENT_PID.
243 help_output = subprocess.run(
244 ["pageant", "--help"],
245 executable=executable,
246 env=env,
247 capture_output=True,
248 text=True,
249 check=False,
250 ).stdout
251 help_lines = help_output.splitlines(True)
252 pageant_version_string = (
253 help_lines[1].strip().removeprefix("Release ")
254 if len(help_lines) >= 2
255 else ""
256 )
257 v0_82 = packaging.version.Version("0.82")
258 pageant_version = packaging.version.Version(pageant_version_string)
260 if pageant_version < v0_82: # pragma: no cover [external]
261 return None
263 return subprocess.Popen(
264 ["pageant", "--foreground", "-s"],
265 executable=executable,
266 stdin=subprocess.DEVNULL,
267 stdout=subprocess.PIPE,
268 shell=False,
269 env=env,
270 text=True,
271 bufsize=1,
272 )
275def spawn_openssh_agent_on_posix( # pragma: no cover [external]
276 executable: str | None, env: dict[str, str]
277) -> subprocess.Popen[str] | None:
278 """Spawn an isolated OpenSSH agent (on UNIX), if possible.
280 Args:
281 executable:
282 The path to the OpenSSH agent executable.
283 env:
284 The new environment for the OpenSSH agent. Should typically
285 not include an SSH_AUTH_SOCK variable.
287 Returns:
288 The spawned OpenSSH agent subprocess. If the executable is
289 `None`, then return `None` directly.
291 It is the caller's responsibility to clean up the spawned
292 subprocess.
294 """
295 if os.name == "nt": # pragma: no cover [external]
296 return None
297 if executable is None: # pragma: no cover [external]
298 executable = shutil.which("ssh-agent")
299 if executable is None:
300 return None
301 return subprocess.Popen(
302 ["ssh-agent", "-D", "-s"],
303 executable=executable,
304 stdin=subprocess.DEVNULL,
305 stdout=subprocess.PIPE,
306 shell=False,
307 env=env,
308 text=True,
309 bufsize=1,
310 )
313def spawn_noop( # pragma: no cover [unused]
314 executable: str | None, env: dict[str, str]
315) -> None:
316 """Placeholder function. Does nothing."""
319def interface_pageant_on_the_annoying_os(
320 executable: str | None,
321 env: dict[str, Any],
322) -> tuple[_types.SSHAgentSocket, str] | None: # pragma: no cover [external]
323 """Interface a Pageant instance on The Annoying OS, if possible.
325 Args:
326 env:
327 (Ignored.)
329 Returns:
330 An abstract socket connected to Pageant, if possible; else
331 `None`.
333 It is the caller's responsibility to clean up the socket
334 (through use of the context manager protocol).
336 """
337 del executable, env
338 try:
339 socket = socketprovider.SocketProvider.windows_named_pipe_for_pageant()
340 except socketprovider.WindowsNamedPipesNotAvailableError:
341 return None
342 else:
343 return (socket, socket.named_pipe_name())
346def interface_openssh_agent_on_the_annoying_os(
347 executable: str | None,
348 env: dict[str, Any],
349) -> tuple[_types.SSHAgentSocket, str] | None: # pragma: no cover [external]
350 """Interface an OpenSSH agent instance on The Annoying OS, if possible.
352 Args:
353 env:
354 (Ignored.)
356 Returns:
357 An abstract socket connected to the OpenSSH agent, if possible;
358 else `None`.
360 It is the caller's responsibility to clean up the socket
361 (through use of the context manager protocol).
363 """
364 del executable, env
365 try:
366 socket = socketprovider.SocketProvider.windows_named_pipe_for_openssh()
367 except socketprovider.WindowsNamedPipesNotAvailableError:
368 return None
369 else:
370 return (socket, socket.named_pipe_name())
373class SpawnHandler(NamedTuple):
374 """A handler for a spawned or interfaced SSH agent.
376 (The choice of terminology "spawn" instead of the more accurate
377 "spawn or interface" is due to historical reasons.)
379 Attributes:
380 instance:
381 The SSH agent instance type under which this handler is
382 registered.
384 This is a full enum, not just a string. In particular, the
385 enum can be queried for the type of agent represented by
386 this entry.
387 executable:
388 The (optional) full path to the executable.
389 spawn_func:
390 The spawn function.
391 agent_name:
392 The (optional) display name for this handler.
394 """
396 instance: data.KnownSSHAgentInstance
397 executable: str | None
398 spawn_func: SSHAgentSpawnFunc | SSHAgentInterfaceFunc
399 agent_name: str | None
402spawn_handlers: dict[str, SpawnHandler] = {
403 data.KnownSSHAgentInstance.unix_pageant: SpawnHandler(
404 data.KnownSSHAgentInstance.unix_pageant,
405 None,
406 spawn_pageant_on_posix,
407 "Pageant (UNIX)",
408 ),
409 data.KnownSSHAgentInstance.openssh: SpawnHandler(
410 data.KnownSSHAgentInstance.openssh,
411 None,
412 spawn_openssh_agent_on_posix,
413 "ssh-agent (OpenSSH)",
414 ),
415 data.KnownSSHAgentInstance.pageant: SpawnHandler(
416 data.KnownSSHAgentInstance.pageant,
417 None,
418 interface_pageant_on_the_annoying_os,
419 "Pageant",
420 ),
421 data.KnownSSHAgentInstance.openssh_on_windows: SpawnHandler(
422 data.KnownSSHAgentInstance.openssh_on_windows,
423 None,
424 interface_openssh_agent_on_the_annoying_os,
425 "ssh-agent (OpenSSH on Windows)",
426 ),
427 data.KnownSSHAgentInstance.stub_agent_with_address: SpawnHandler(
428 data.KnownSSHAgentInstance.stub_agent_with_address,
429 None,
430 spawn_noop,
431 "stub_agent_with_address (derivepassphrase test suite)",
432 ),
433 data.KnownSSHAgentInstance.stub_agent_with_address_and_deterministic_dsa: SpawnHandler(
434 data.KnownSSHAgentInstance.stub_agent_with_address_and_deterministic_dsa,
435 None,
436 spawn_noop,
437 "stub_agent_with_address_and_deterministic_dsa "
438 "(derivepassphrase test suite)",
439 ),
440 data.KnownSSHAgentInstance.system_agent: SpawnHandler(
441 data.KnownSSHAgentInstance.system_agent,
442 None,
443 spawn_noop,
444 None,
445 ),
446}
447"""
448The standard registry of agent spawning/interfacing functions.
450(The choice of terminology "spawn" instead of the more accurate "spawn
451or interface" is due to historical reasons.)
452"""
455def external_agent_restriction(
456 agent_type: data.KnownSSHAgentType,
457 env_var: str,
458 *,
459 default: bool = True,
460) -> bool: # pragma: no cover [external]
461 """Classify an SSH agent according to some environment variable.
463 Look up the given SSH agent type in a certain environment variable,
464 and report on whether the agent type is listed in that variable.
465 This can be used to check if the agent is e.g. in a list of
466 permitted agents, or if it is in the list of agents requiring
467 special workarounds. Invalid agent type names are silently ignored.
469 If the environment variable is empty, return a default value.
471 By design, the stubbed agents have a fixed classification (the
472 default value), unaffected by the environment variable.
474 Args:
475 agent_type:
476 The agent type to classify.
477 env_var:
478 The environment variable in which to look up the agent type
479 name.
480 default:
481 The value to return if the environment variable is unset, or
482 if a stub agent is to be classified.
484 Returns:
485 If the agent type is not a stub agent, and the environment
486 variable is non-empty, then `True` if the agent type is
487 mentioned in the environment variable, and `False` otherwise;
488 the default value otherwise.
490 See also:
491 [`is_agent_permitted`][] and [`is_agent_non_reentrant`][], which
492 make use of this function internally.
494 """
495 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent:
496 return default
497 if not os.environ.get(env_var):
498 return default
499 marked_agents = {
500 data.KnownSSHAgentType(x)
501 for x in os.environ[env_var].split(",")
502 if x in data.KnownSSHAgentType.__members__
503 }
504 return agent_type in marked_agents
507def is_agent_permitted(
508 agent_type: data.KnownSSHAgentType,
509) -> bool: # pragma: no cover [external]
510 """May the given SSH agent be spawned by the test harness?
512 If the environment variable `PERMITTED_SSH_AGENTS` is given, it
513 names a comma-separated list of known SSH agent names that the test
514 harness may spawn. Invalid names are silently ignored. If not
515 given, or empty, then any agent may be spawned.
517 (To not allow any agent to be spawned, specify a single comma as the
518 list. But see below.)
520 The stubbed agents cannot be restricted in this manner, as the test
521 suite depends on their availability.
523 """
524 return external_agent_restriction(
525 agent_type, "PERMITTED_SSH_AGENTS", default=True
526 )
529def is_agent_non_reentrant(
530 agent_type: data.KnownSSHAgentType,
531) -> bool: # pragma: no cover [external]
532 """Is the given SSH agent assumed to be non-reentrant?
534 If the environment variable `NON_REENTRANT_SSH_AGENTS` is given, it
535 names a comma-separated list of known SSH agent names that are
536 assumed to be non-reentrant, i.e., where the SSH agent cannot
537 tolerate multiple clients connecting to it at the same time, and
538 thus no client for the same agent may be constructed while another
539 one is already connected. Invalid names are silently ignored. If
540 not given, or empty, then all agents are considered reentrant, i.e.,
541 all agents are not considered non-reentrant.
543 (To consider all agents non-reentrant, specify a single comma as the
544 list. But see below.)
546 The stubbed agents cannot be influenced in this manner, as the test
547 suite depends on their reentrancy.
549 """
550 return external_agent_restriction(
551 agent_type, "NON_REENTRANT_SSH_AGENTS", default=False
552 )
555spawn_handlers_params: list[Sequence] = []
556"""
557The standard registry of agent spawning functions, annotated as
558a `pytest` parameter set. (In particular, this includes the conditional
559skip marks.) Used by some test fixtures.
561(The choice of terminology "spawn" instead of the more accurate "spawn
562or interface" is due to historical reasons.)
563"""
564for instance, handler in spawn_handlers.items():
565 marks = [
566 pytest.mark.skipif(
567 not is_agent_permitted(handler.instance.agent_type),
568 reason="SSH agent excluded via PERMITTED_AGENTS "
569 "environment variable.",
570 ),
571 ]
572 agent_type = handler.instance.agent_type
573 # Distinguish spawned/interfaced agents ("external agents") from the
574 # stub agents ("internal agents"). While this could be inferred from
575 # the other marks, it is sensible to have a dedicated mark for this.
576 # Tests that unnecessarily use external agents without testing anything
577 # that is specific to external agents can be found more easily using
578 # this mark, and converted to using internal agents only, with speed and
579 # flakiness-resistance benefits.
580 if agent_type != data.KnownSSHAgentType.StubbedSSHAgent:
581 marks.append(pytest.mark.external_agent)
582 # Allow agents to be marked as non-reentrant. Workaround for an
583 # issue with GnuPG 2.4.8 on The Annoying OS when `gpg-agent`
584 # masquerades as OpenSSH's `ssh-agent`.
585 if is_agent_non_reentrant(agent_type): # pragma: no cover [external]
586 marks.append(pytest.mark.non_reentrant_agent)
587 # Mark non-isolated agents as, well, using non-isolated agents.
588 # Assume by default that an agent is potentially non-isolated unless
589 # we can be absolutely sure it is isolated, by design. (The "stub"
590 # agents fall into the latter category.)
591 if agent_type != data.KnownSSHAgentType.StubbedSSHAgent:
592 marks.append(pytest_machinery.non_isolated_agent_use)
593 # Some agents require UNIX domain socket support.
594 if instance in {
595 data.KnownSSHAgentInstance.unix_pageant,
596 data.KnownSSHAgentInstance.openssh,
597 }:
598 marks.append(
599 pytest.mark.skipif(
600 not hasattr(socket, "AF_UNIX"),
601 reason="No Python or system support for UNIX domain sockets.",
602 )
603 )
604 # Others require Windows named pipe support.
605 if instance in {
606 data.KnownSSHAgentInstance.pageant,
607 data.KnownSSHAgentInstance.openssh_on_windows,
608 }:
609 marks.append(
610 pytest.mark.skipif(
611 not hasattr(ctypes, "WinDLL"),
612 reason="No Python or system support for Windows named pipes.",
613 )
614 )
615 spawn_handlers_params.append(
616 pytest.param(handler, id=instance, marks=marks)
617 )
620Popen = TypeVar("Popen", bound=subprocess.Popen)
623# As of v0.6, all SSH agents we interact with on The Annoying OS are
624# interfaced, not spawned. Therefore, this context manager is unused on
625# The Annoying OS.
626@contextlib.contextmanager
627def terminate_on_exit(
628 proc: Popen,
629) -> Generator[Popen, None, None]: # pragma: unless posix no cover [unused]
630 """Terminate and wait for the subprocess upon exiting the context.
632 Args:
633 proc:
634 The subprocess to manage.
636 Returns:
637 A context manager. Upon entering the manager, return the
638 managed subprocess. Upon exiting the manager, terminate the
639 process and wait for it.
641 """
642 try:
643 yield proc
644 finally:
645 proc.terminate()
646 proc.wait()
649class CannotSpawnError(RuntimeError):
650 """Cannot spawn the SSH agent."""
653def spawn_named_agent( # noqa: C901
654 executable: str | None,
655 spawn_func: SSHAgentSpawnFunc | SSHAgentInterfaceFunc,
656 agent_type: data.KnownSSHAgentType,
657 agent_name: str,
658) -> Generator[
659 data.SpawnedSSHAgentInfo, None, None
660]: # pragma: no cover [external]
661 """Spawn the named SSH agent and check that it is operational.
663 Using the correct agent-specific spawn function from the
664 [`spawn_handlers`][] registry, spawn the named SSH agent (according
665 to its declared type), then set up the communication channel and
666 yield an SSH agent client connected to this agent. After resuming,
667 tear down the communication channel and terminate the SSH agent.
669 The SSH agent's instructions for setting up the communication
670 channel are parsed with [`callables.parse_sh_export_line`][]. See
671 the caveats there.
673 (The choice of terminology "spawned" instead of the more accurate
674 "spawned or interfaced" is due to historical reasons.)
676 Args:
677 executable:
678 The full path of the executable to spawn. If not given, we
679 attempt to spawn the agent under its conventional executable
680 name.
681 spawn_func:
682 The agent-specific spawn function.
683 agent_type:
684 The agent type.
685 agent_name:
686 The agent's display name.
688 Yields:
689 A 3-tuple containing the agent type, an SSH agent client
690 connected to this agent, and a boolean indicating whether this
691 agent was actually spawned in an isolated manner.
693 Only one tuple will ever be yielded. After resuming, the
694 connected client will be torn down, as will the agent if it was
695 isolated.
697 Raises:
698 CannotSpawnError:
699 We failed to spawn the agent or otherwise set up the
700 environment/communication channel/etc.
702 """
703 # pytest's fixture system does not seem to guarantee that
704 # environment variables are set up correctly if nested and
705 # parametrized fixtures are used: it is possible that "outer"
706 # parametrized fixtures are torn down only after other "outer"
707 # fixtures of the same parameter set have run. So our fixtures set
708 # SSH_AUTH_SOCK explicitly to the value saved at interpreter
709 # startup.
710 #
711 # Here, we verify at most major steps that SSH_AUTH_SOCK didn't
712 # change under our nose.
713 assert os.environ.get("SSH_AUTH_SOCK") == startup_ssh_auth_sock, (
714 f"SSH_AUTH_SOCK mismatch when checking for spawnable {agent_name}"
715 )
716 exit_stack = contextlib.ExitStack()
717 agent_env = os.environ.copy()
718 ssh_auth_sock = agent_env.pop("SSH_AUTH_SOCK", None)
719 proc_or_socket_data = spawn_func(executable=executable, env=agent_env)
720 with exit_stack:
721 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent:
722 ssh_auth_sock = None
723 elif spawn_func is spawn_noop:
724 ssh_auth_sock = os.environ.get("SSH_AUTH_SOCK")
725 elif proc_or_socket_data is None: # pragma: no cover [external]
726 err_msg = f"Cannot spawn or interface with usable {agent_name}"
727 raise CannotSpawnError(err_msg)
728 elif isinstance(proc_or_socket_data, subprocess.Popen):
729 proc = proc_or_socket_data
730 exit_stack.enter_context(terminate_on_exit(proc))
731 assert os.environ.get("SSH_AUTH_SOCK") == startup_ssh_auth_sock, (
732 f"SSH_AUTH_SOCK mismatch after spawning {agent_name}"
733 )
734 assert proc.stdout is not None
735 ssh_auth_sock_line = proc.stdout.readline()
736 try:
737 ssh_auth_sock = callables.parse_sh_export_line(
738 ssh_auth_sock_line, env_name="SSH_AUTH_SOCK"
739 )
740 except ValueError: # pragma: no cover [external]
741 err_msg = f"Cannot parse agent output: {ssh_auth_sock_line!r}"
742 raise CannotSpawnError(err_msg) from None
743 pid_line = proc.stdout.readline()
744 if (
745 "pid" not in pid_line.lower()
746 and "_pid" not in pid_line.lower()
747 ): # pragma: no cover [external]
748 err_msg = f"Cannot parse agent output: {pid_line!r}"
749 raise CannotSpawnError(err_msg)
750 else:
751 ssh_auth_sock = proc_or_socket_data[1]
752 monkeypatch = exit_stack.enter_context(pytest.MonkeyPatch.context())
753 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent:
754 assert ssh_auth_sock is None
755 monkeypatch.setenv(
756 "SSH_AUTH_SOCK",
757 machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
758 )
759 monkeypatch.setattr(
760 ssh_agent.SSHAgentClient,
761 "SOCKET_PROVIDERS",
762 [
763 data.KnownSSHAgentInstance.stub_agent_with_address_and_deterministic_dsa
764 ]
765 if "stub_agent_with_address_and_deterministic_dsa"
766 in agent_name
767 else [data.KnownSSHAgentInstance.stub_agent_with_address],
768 )
769 client = exit_stack.enter_context(
770 ssh_agent.SSHAgentClient.ensure_agent_subcontext(
771 machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA()
772 if "stub_agent_with_address_and_deterministic_dsa"
773 in agent_name
774 else machinery.StubbedSSHAgentSocketWithAddress()
775 )
776 )
777 elif (
778 not isinstance(proc_or_socket_data, subprocess.Popen)
779 and proc_or_socket_data is not None
780 ):
781 socket: _types.SSHAgentSocket
782 socket, ssh_auth_sock = proc_or_socket_data
783 monkeypatch.setenv("SSH_AUTH_SOCK", ssh_auth_sock)
784 client = exit_stack.enter_context(
785 ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn=socket)
786 )
787 else:
788 if ssh_auth_sock:
789 monkeypatch.setenv("SSH_AUTH_SOCK", ssh_auth_sock)
790 client = exit_stack.enter_context(
791 ssh_agent.SSHAgentClient.ensure_agent_subcontext()
792 )
793 # We sanity-test the connected SSH agent if it is not one of our
794 # test agents, because allowing the user to run the test suite
795 # with a clearly faulty agent would likely and unfairly
796 # misattribute the agent's protocol violations to our test
797 # suite. On the flip side, for our own test agents, the
798 # correctness tests are part of the test suite, so we don't want
799 # the *setup* code here to already decide that the agent is
800 # unsuitable. Therefore, do a sanity check if and only if the
801 # agent is not one of our test agents, and if the check fails,
802 # skip this agent.
803 if (
804 agent_type != data.KnownSSHAgentType.StubbedSSHAgent
805 ): # pragma: no cover [external]
806 try:
807 client.list_keys() # sanity test
808 except (
809 EOFError,
810 OSError,
811 ssh_agent.SSHAgentFailedError,
812 ) as exc: # pragma: no cover [failsafe]
813 msg = f'agent failed the "list keys" sanity test: {exc!r}'
814 raise CannotSpawnError(msg) from exc
815 yield data.SpawnedSSHAgentInfo(
816 agent_type,
817 client,
818 spawn_func is not spawn_noop
819 if isinstance(proc_or_socket_data, subprocess.Popen)
820 else False,
821 )
822 assert os.environ.get("SSH_AUTH_SOCK", None) == startup_ssh_auth_sock, (
823 f"SSH_AUTH_SOCK mismatch after tearing down {agent_name}"
824 )
827# Hack: We cannot associate markers with fixtures (the pytest devs say
828# there are many unresolved questions when it comes to fixture and
829# marker overriding in such a case), so we fake this by trivially
830# parametrizing the fixture. (The parametrizations *can* contain the
831# necessary marks.)
832@pytest.fixture(
833 params=[
834 pytest.param(
835 "non_isolated_agent_use",
836 marks=pytest_machinery.non_isolated_agent_use,
837 )
838 ]
839)
840def running_ssh_agent( # pragma: no cover [external]
841 request: pytest.FixtureRequest,
842) -> Generator[data.RunningSSHAgentInfo, None, None]:
843 """Ensure a running SSH agent, if possible, as a pytest fixture.
845 Check for a running SSH agent, or spawn/interface a new one if
846 possible. We know how to spawn OpenSSH's agent and PuTTY's Pageant
847 on UNIX, or interface them on The Annoying OS. If spawned this way,
848 the agent does not persist beyond the test.
850 This fixture can neither guarantee a particular running agent, nor
851 can it guarantee a particular set of loaded keys.
853 Yields:
854 A 2-tuple `(ssh_auth_sock, agent_type)`, where `ssh_auth_sock`
855 is the value of the `SSH_AUTH_SOCK` environment variable, to be
856 used to connect to the running agent, and `agent_type` is the
857 agent type.
859 Raises:
860 pytest.skip.Exception:
861 If no agent is running or can be spawned, skip this test.
863 """
864 del request
866 def prepare_environment(
867 agent_type: data.KnownSSHAgentType,
868 ) -> Generator[data.RunningSSHAgentInfo, None, None]:
869 with pytest.MonkeyPatch.context() as monkeypatch:
870 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent:
871 monkeypatch.setattr(
872 ssh_agent.SSHAgentClient,
873 "SOCKET_PROVIDERS",
874 [
875 _types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS
876 ],
877 )
878 monkeypatch.setenv(
879 "SSH_AUTH_SOCK",
880 machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
881 )
882 yield data.RunningSSHAgentInfo(
883 machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA,
884 data.KnownSSHAgentType.StubbedSSHAgent,
885 )
886 else:
887 yield data.RunningSSHAgentInfo(
888 os.environ["SSH_AUTH_SOCK"],
889 agent_type,
890 )
892 with pytest.MonkeyPatch.context() as monkeypatch:
893 # pytest's fixture system does not seem to guarantee that
894 # environment variables are set up correctly if nested and
895 # parametrized fixtures are used: it is possible that "outer"
896 # parametrized fixtures are torn down only after other "outer"
897 # fixtures of the same parameter set have run. So set
898 # SSH_AUTH_SOCK explicitly to the value saved at interpreter
899 # startup.
900 if startup_ssh_auth_sock:
901 monkeypatch.setenv("SSH_AUTH_SOCK", startup_ssh_auth_sock)
902 else:
903 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
904 for handler in spawn_handlers.values():
905 if not is_agent_permitted(handler.instance.agent_type):
906 continue
907 try:
908 for _agent_info in spawn_named_agent(
909 executable=handler.executable,
910 spawn_func=handler.spawn_func,
911 agent_type=handler.instance.agent_type,
912 agent_name=handler.agent_name or handler.instance,
913 ):
914 yield from prepare_environment(handler.instance.agent_type)
915 except (KeyError, OSError, CannotSpawnError):
916 continue
917 return
918 pytest.skip("No SSH agent running or spawnable")
921@pytest.fixture(params=spawn_handlers_params)
922def spawn_ssh_agent(
923 request: pytest.FixtureRequest,
924) -> Generator[
925 data.SpawnedSSHAgentInfo, None, None
926]: # pragma: no cover [external]
927 """Spawn or interface an SSH agent, as a pytest fixture.
929 Spawn a new SSH agent (isolated from other SSH use by other
930 processes, on a best-effort basis), or interface a running
931 (non-isolated) SSH agent. We know how to spawn OpenSSH's agent (on
932 UNIX) and PuTTY's Pageant (on UNIX) and the stubbed agents, and how
933 to interface PuTTY's Pageant (on The Annoying OS), OpenSSH's agent
934 (on The Annoying OS) and the "system" agent (on every OS).
936 (The choice of terminology "spawned" instead of the more accurate
937 "spawned or interfaced" is due to historical reasons.)
939 Yields:
940 A [named tuple][collections.namedtuple] containing information
941 about the spawned agent, e.g. the software product, a client
942 connected to the agent, and whether the agent is isolated from
943 other clients.
945 Raises:
946 pytest.skip.Exception:
947 If the agent cannot be spawned or interfaced, skip this
948 test.
950 """
952 with pytest.MonkeyPatch.context() as monkeypatch:
953 # pytest's fixture system does not seem to guarantee that
954 # environment variables are set up correctly if nested and
955 # parametrized fixtures are used: it is possible that "outer"
956 # parametrized fixtures are torn down only after other "outer"
957 # fixtures of the same parameter set have run. So set
958 # SSH_AUTH_SOCK explicitly to the value saved at interpreter
959 # startup.
960 if startup_ssh_auth_sock: # pragma: no cover [external]
961 monkeypatch.setenv("SSH_AUTH_SOCK", startup_ssh_auth_sock)
962 else: # pragma: no cover [external]
963 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
964 try:
965 yield from spawn_named_agent(
966 executable=request.param.executable,
967 spawn_func=request.param.spawn_func,
968 agent_type=request.param.instance.agent_type,
969 agent_name=request.param.agent_name or request.param.instance,
970 )
971 except KeyError as exc:
972 pytest.skip(
973 f"The environment variable {exc.args[0]!r} was not found"
974 if exc.__class__ is KeyError
975 else exc.args[0]
976 )
977 except (OSError, CannotSpawnError) as exc:
978 name = request.param.agent_name or request.param.instance
979 pytest.skip(f"Cannot spawn {name}: {exc}")
980 return
983def _prepare_payload(
984 payload: bytes | bytearray,
985 *,
986 isolated: bool = True,
987 time_to_live: int = 30,
988) -> tuple[_types.SSH_AGENTC, bytes]:
989 """Return a full payload for an "add key" request to the agent.
991 For isolated agents, return a standard
992 [`_types.SSH_AGENTC.ADD_IDENTITY`][] request payload. For
993 non-isolated agents, return a
994 [`_types.SSH_AGENTC.ADD_ID_CONSTRAINED`][] request payload with the
995 specified time-to-live value.
997 Args:
998 payload:
999 The private key to upload, already formatted suitably.
1000 isolated:
1001 `True` if the agent is isolated, `False` otherwise.
1002 time_to_live:
1003 The amount of seconds to hold the key in the agent, if not
1004 isolated.
1006 """
1007 return_code = (
1008 _types.SSH_AGENTC.ADD_IDENTITY
1009 if isolated
1010 else _types.SSH_AGENTC.ADD_ID_CONSTRAINED
1011 )
1012 lifetime_constraint = (
1013 b""
1014 if isolated
1015 else b"\x01" + ssh_agent.SSHAgentClient.uint32(time_to_live)
1016 )
1017 return (return_code, bytes(payload) + lifetime_constraint)
1020def _load_key_optimistically(
1021 spawned_agent_info: data.SpawnedSSHAgentInfo,
1022 key_struct: data.SSHTestKey,
1023) -> bool:
1024 """Load a test key optimistically into the spawned agent.
1026 Load the key with a time-to-live constraint (if isolated) or not, as
1027 appropriate. If that fails because this agent does not support key
1028 constraints, retry it without constraints.
1030 Args:
1031 spawned_agent_info: Info on the spawned agent.
1032 key_struct: The struct for the SSH test key to upload.
1034 Returns:
1035 `True` if the key was successfully uploaded, `False` otherwise.
1036 Retrying an upload because key constraints are not supported
1037 still counts as successfully uploaded.
1039 """
1040 agent_type, client, isolated = spawned_agent_info
1041 private_key_data = key_struct.private_key_blob
1042 request_code, payload = _prepare_payload(
1043 private_key_data, isolated=isolated, time_to_live=30
1044 )
1045 try:
1046 try:
1047 client.request(
1048 request_code,
1049 payload,
1050 response_code=_types.SSH_AGENT.SUCCESS,
1051 )
1052 except ssh_agent.SSHAgentFailedError: # pragma: no cover [external]
1053 # Pageant can fail to accept a key for two separate reasons:
1054 #
1055 # - Pageant refuses to accept a key it already holds in
1056 # memory. Verify this by listing keys.
1057 # - Pageant does not support key constraints (see
1058 # references below).
1059 #
1060 # https://www.chiark.greenend.org.uk/~sgtatham/putty/wishlist/pageant-timeout.html
1061 # https://www.chiark.greenend.org.uk/~sgtatham/putty/wishlist/pageant-key-confirm.html
1062 current_loaded_keys = frozenset({
1063 pair.key for pair in client.list_keys()
1064 })
1065 if (
1066 agent_type
1067 in {
1068 data.KnownSSHAgentType.UNIXPageant,
1069 data.KnownSSHAgentType.Pageant,
1070 }
1071 and key_struct.public_key_data in current_loaded_keys
1072 ):
1073 pass
1074 elif (
1075 agent_type
1076 in {
1077 data.KnownSSHAgentType.UNIXPageant,
1078 data.KnownSSHAgentType.Pageant,
1079 }
1080 and not isolated
1081 ):
1082 request_code, payload = _prepare_payload(
1083 private_key_data, isolated=True
1084 )
1085 client.request(
1086 request_code,
1087 payload,
1088 response_code=_types.SSH_AGENT.SUCCESS,
1089 )
1090 else:
1091 raise
1092 except (
1093 EOFError,
1094 OSError,
1095 ssh_agent.SSHAgentFailedError,
1096 ): # pragma: no cover [external]
1097 return False
1098 else: # pragma: no cover [external]
1099 return True
1102@pytest.fixture
1103def ssh_agent_client_with_test_keys_loaded(
1104 spawn_ssh_agent: data.SpawnedSSHAgentInfo,
1105) -> Generator[ssh_agent.SSHAgentClient, None, None]:
1106 """Provide an SSH agent with loaded test keys, as a pytest fixture.
1108 Use the `spawn_ssh_agent` fixture to acquire a usable SSH agent,
1109 upload the known test keys into the agent, and return a connected
1110 client.
1112 The agent may reject several of the test keys due to unsupported or
1113 obsolete key types. Rejected keys will be silently ignored, unless
1114 all keys are rejected; then the test will be skipped. You must not
1115 automatically assume any particular key is present in the agent.
1117 Yields:
1118 A [named tuple][collections.namedtuple] containing
1119 information about the spawned agent, e.g. the software
1120 product, a client connected to the agent, and whether the
1121 agent is isolated from other clients.
1123 Raises:
1124 OSError:
1125 There was a communication or a socket setup error with the
1126 agent.
1127 pytest.skip.Exception:
1128 If the agent is unusable or if it rejected all test keys,
1129 skip this test.
1131 Warning:
1132 It is this fixture's responsibility to clean up the SSH agent
1133 client after the test. Closing the client's socket connection
1134 beforehand (e.g. by using the client as a context manager) may
1135 lead to exceptions being thrown upon fixture teardown.
1137 """
1138 agent_type, client, isolated = spawn_ssh_agent
1139 successfully_loaded_keys: set[str] = set()
1141 # This fixture relies on `spawn_ssh_agent`, which in turn uses
1142 # `spawn_named_agent` to, well, spawn agents. `spawn_named_agent`
1143 # runs sanity tests on the agents it spawns, via
1144 # `SSHAgentClient.list_keys`. There is thus little point in
1145 # repeating the very same sanity test here, on an agent that was
1146 # already sanity-tested.
1147 #
1148 # (But see below, too.)
1150 try:
1151 successfully_loaded_keys = {
1152 key_type
1153 for key_type, key_struct in data.ALL_KEYS.items()
1154 if _load_key_optimistically(spawn_ssh_agent, key_struct)
1155 }
1156 if (
1157 agent_type != data.KnownSSHAgentType.StubbedSSHAgent
1158 and not successfully_loaded_keys
1159 ): # pragma: no cover [external]
1160 pytest.skip("Failed to load any test keys at all into the agent.")
1161 # Sanity-test the agent. It makes sense to do this here (unlike
1162 # at the top of this fixture), because we ignore errors when
1163 # adding keys above, but badly behaved SSH agents such as the
1164 # Annoying OS port of OpenSSH 10.0p1 (Dec 2025) may have already
1165 # closed the connection.
1166 try:
1167 client.list_keys()
1168 except (
1169 EOFError,
1170 OSError,
1171 ssh_agent.SSHAgentFailedError,
1172 ): # pragma: no cover [external]
1173 pytest.skip(
1174 "The agent became unusable during test fixture setup. "
1175 "(Sanity check failed.)"
1176 )
1177 yield client
1178 finally:
1179 request_code = _types.SSH_AGENTC.REMOVE_IDENTITY
1180 # For isolated agents, we have full control over the loaded key
1181 # set, and thus expect to successfully unload every key. For
1182 # non-isolated agents, the user or another process may load or
1183 # unload keys in the meantime, so we cannot assume that the
1184 # operation will succeed.
1185 response_code = (
1186 frozenset({_types.SSH_AGENT.SUCCESS})
1187 if isolated
1188 else frozenset({
1189 _types.SSH_AGENT.SUCCESS,
1190 _types.SSH_AGENT.FAILURE,
1191 })
1192 )
1193 for key_type in successfully_loaded_keys:
1194 key_struct = data.ALL_KEYS[key_type]
1195 # The public key blob is the base64-encoded part in
1196 # the "public key line".
1197 public_key = base64.standard_b64decode(
1198 key_struct.public_key.split(None, 2)[1]
1199 )
1200 # Though unnecessary for isolated agents, unloading the key
1201 # is vital functionality for non-isolated agents. We thus
1202 # always unload keys, and use the "isolated agent" case as
1203 # a smoke test for the more brittle "non-isolated agent"
1204 # case.
1205 client.request(
1206 request_code,
1207 ssh_agent.SSHAgentClient.string(public_key),
1208 response_code=response_code,
1209 )